diff --git a/.github/workspace-dep-closures.json b/.github/workspace-dep-closures.json index 9a7b62b276c..a29e44007cc 100644 --- a/.github/workspace-dep-closures.json +++ b/.github/workspace-dep-closures.json @@ -56,17 +56,24 @@ "crates/ai_toolset", "crates/ai_usage", "crates/attachment", + "crates/authentication_service_client", "crates/bot_id", "crates/bots", + "crates/call", "crates/channel_sender", "crates/channels", + "crates/connection", "crates/connection_gateway_client", "crates/connection_gateway_models", "crates/contacts", "crates/cowlike", + "crates/crm", "crates/document_sub_type", + "crates/email", + "crates/email_utils", "crates/entity_access", "crates/entity_access_db_utils", + "crates/entity_access_management", "crates/entity_mutation", "crates/filter_ast", "crates/frecency", @@ -74,8 +81,10 @@ "crates/item_filters", "crates/kafka_util", "crates/last_online_tracker", + "crates/lexical_client", "crates/macro_auth", "crates/macro_authorization", + "crates/macro_aws_config", "crates/macro_env", "crates/macro_env_var", "crates/macro_event_broker", @@ -92,15 +101,26 @@ "crates/model_user", "crates/models_bulk_upload", "crates/models_entity_access_management", + "crates/models_opensearch", "crates/models_pagination", "crates/models_permissions", + "crates/models_properties", + "crates/models_search", + "crates/models_search_cursor", + "crates/models_soup", "crates/non_empty", "crates/notification", + "crates/opensearch_client", + "crates/opensearch_query_builder", "crates/prompt", + "crates/properties", "crates/rate_limit", + "crates/reminders", "crates/remote_env_var", "crates/share_permission_db_utils", "crates/sqs_worker", + "crates/system_properties", + "crates/unfurl", "crates/workspace-hack" ], "agent_harness_service": [ @@ -114,18 +134,25 @@ "crates/ai_toolset", "crates/ai_usage", "crates/attachment", + "crates/authentication_service_client", "crates/bot_id", "crates/bots", + "crates/call", "crates/channel_sender", "crates/channels", + "crates/connection", "crates/connection_gateway_client", "crates/connection_gateway_models", "crates/contacts", "crates/cowlike", + "crates/crm", "crates/database_env_vars", "crates/document_sub_type", + "crates/email", + "crates/email_utils", "crates/entity_access", "crates/entity_access_db_utils", + "crates/entity_access_management", "crates/entity_mutation", "crates/filter_ast", "crates/frecency", @@ -133,6 +160,7 @@ "crates/item_filters", "crates/kafka_util", "crates/last_online_tracker", + "crates/lexical_client", "crates/macro_auth", "crates/macro_authorization", "crates/macro_aws_config", @@ -156,15 +184,26 @@ "crates/model_user", "crates/models_bulk_upload", "crates/models_entity_access_management", + "crates/models_opensearch", "crates/models_pagination", "crates/models_permissions", + "crates/models_properties", + "crates/models_search", + "crates/models_search_cursor", + "crates/models_soup", "crates/non_empty", "crates/notification", + "crates/opensearch_client", + "crates/opensearch_query_builder", "crates/prompt", + "crates/properties", "crates/rate_limit", + "crates/reminders", "crates/remote_env_var", "crates/share_permission_db_utils", "crates/sqs_worker", + "crates/system_properties", + "crates/unfurl", "crates/workspace-hack", "services/agent_harness_service" ], @@ -349,6 +388,8 @@ "ai_projections": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_projections", "crates/ai_tools", "crates/ai_toolset", @@ -526,6 +567,8 @@ "ai_tools": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_tools", "crates/ai_toolset", "crates/ai_usage", @@ -679,6 +722,8 @@ "authentication_service": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/analytics_client", @@ -832,6 +877,8 @@ "bots": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -941,6 +988,8 @@ "call": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -1004,6 +1053,8 @@ "channel_bots": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_tools", "crates/ai_toolset", "crates/ai_usage", @@ -1123,6 +1174,8 @@ "channels": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -1214,6 +1267,8 @@ "comms_db_client": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -1267,6 +1322,8 @@ "complete_graph": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -1679,6 +1736,8 @@ "deleted_item_poller": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -1777,6 +1836,8 @@ "document_cognition_service": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_projections", "crates/ai_tools", "crates/ai_toolset", @@ -2113,6 +2174,8 @@ "document_upload_finalizer_handler": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -2198,6 +2261,8 @@ "documents": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -3014,6 +3079,8 @@ "github": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -3110,6 +3177,8 @@ "graphql_channel": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -3243,6 +3312,8 @@ "graphql_entity_mutation": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -3482,6 +3553,8 @@ "graphql_soup": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -3737,6 +3810,8 @@ "lexical_client": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -3809,6 +3884,8 @@ "lexical_mention_extractor": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -4158,6 +4235,8 @@ "mcp_service": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_tools", "crates/ai_toolset", "crates/ai_usage", @@ -4275,6 +4354,8 @@ "memory": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_tools", "crates/ai_toolset", "crates/ai_usage", @@ -4518,6 +4599,8 @@ "models_search": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -4597,6 +4680,8 @@ "models_soup": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -4667,6 +4752,8 @@ "name_search": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -5058,6 +5145,8 @@ "projects": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -5338,6 +5427,8 @@ "scheduled_action": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_tools", "crates/ai_toolset", "crates/ai_usage", @@ -5455,6 +5546,8 @@ "search_processing_service": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -5562,6 +5655,8 @@ "search_service": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -5644,6 +5739,8 @@ "search_service_client": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -5755,6 +5852,8 @@ "seed_cli": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -5870,6 +5969,8 @@ "skills": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -5952,6 +6053,8 @@ "soup": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -6022,6 +6125,8 @@ "soup_realtime": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -6304,6 +6409,8 @@ "task_dedup": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", @@ -6380,6 +6487,8 @@ "teams": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/analytics_client", @@ -6637,6 +6746,8 @@ "webhook": [ "crates/activity", "crates/agent", + "crates/agent_fold", + "crates/agent_runtime_protocol", "crates/ai_toolset", "crates/ai_usage", "crates/attachment", diff --git a/Cargo.lock b/Cargo.lock index ae7822a13eb..4368866b688 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,6 +167,9 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "specta", + "specta-serde", + "specta-typescript", "strum 0.27.2", "thiserror 2.0.18", "tokio", @@ -14304,6 +14307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50" dependencies = [ "rustc_version", + "serde_json", "specta-macros", ] diff --git a/apps/web/check-diff.tmp.ts b/apps/web/check-diff.tmp.ts new file mode 100644 index 00000000000..e3fb2d5647d --- /dev/null +++ b/apps/web/check-diff.tmp.ts @@ -0,0 +1,68 @@ +/** Expand an Edit tool card and screenshot the Pierre diff. Temp debug tooling. */ +import { readFileSync } from 'node:fs'; +import { chromium } from '@playwright/test'; + +const RECORDING = `${process.env.HOME}/.agent_runtime_sessions/0c5f554e-2fb0-496e-98cf-6b9e37755f45.jsonl`; +const SESSION_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeffff0001'; +const CHANNEL_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeffff0002'; + +const entries = readFileSync(RECORDING, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const { direction, content } = JSON.parse(line); + return { direction, content }; + }); + +const session = { + id: SESSION_ID, + channelId: CHANNEL_ID, + botId: '00000000-0000-0000-0000-00000000a9e7', + model: 'claude-opus-5', + harness: 'claude-code', + repoUrl: 'https://github.com/macro/cloud-storage', + status: { kind: 'event', event: 'acp_ready' }, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), +}; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1280, height: 1400 } }); +await page.route(`**/dss/agent-sessions/${SESSION_ID}`, (route) => + route.fulfill({ json: session }) +); +await page.route(`**/dss/agent-sessions/channel/${CHANNEL_ID}/log`, (route) => + route.fulfill({ json: { agentSessionId: SESSION_ID, entries } }) +); +page.on('pageerror', (err) => console.log('[pageerror]', String(err).slice(0, 500))); + +await page.goto(`http://localhost:3999/app/agent/${SESSION_ID}`, { + waitUntil: 'domcontentloaded', +}); +await page.waitForTimeout(20_000); + +// Find an expandable Edit card (trigger row whose title is an edit label with a diff badge). +const triggers = page.locator('button.group'); +const count = await triggers.count(); +console.log('expandable cards:', count); +let clicked = false; +for (let i = 0; i < count; i++) { + const text = (await triggers.nth(i).innerText()).replace(/\n/g, ' '); + if (/edit|write/i.test(text)) { + console.log('clicking:', text.slice(0, 120)); + await triggers.nth(i).scrollIntoViewIfNeeded(); + await triggers.nth(i).click(); + clicked = true; + break; + } +} +if (!clicked) console.log('no edit card found — listing first 10 triggers:'); +if (!clicked) + for (let i = 0; i < Math.min(10, count); i++) + console.log(' -', (await triggers.nth(i).innerText()).replace(/\n/g, ' ').slice(0, 100)); + +await page.waitForTimeout(6_000); // let pierre highlight +await page.screenshot({ + path: '/private/tmp/claude-501/-Users-eric-Code-macro/91f094aa-ce54-46ac-8cae-e6ec1f217a24/scratchpad/agent-diff.png', +}); +await browser.close(); diff --git a/apps/web/justfile b/apps/web/justfile index 9cbd9091566..a2b789d38d3 100644 --- a/apps/web/justfile +++ b/apps/web/justfile @@ -188,6 +188,16 @@ ensure-agent-fold-wasm: just --justfile "{{justfile()}}" build-agent-fold-wasm fi +# Regenerate src/lib/service-clients/service-agent-fold/generated/types.ts +# from agent_fold's Rust wire types with specta. Run after changing anything +# under crates/agent_fold/src/inbound/wire.rs. Specta's own output is tabs and +# commas, not this project's style, so biome reformats it in place - without +# that, every regeneration would be a diff-format mismatch even with no real +# change. +gen-agent-fold-types: + cargo run -p agent_fold --bin export_types + bunx biome format --write {{justfile_directory()}}/src/lib/service-clients/service-agent-fold/generated/types.ts + # Dev fast path: build the wasm pkg only when missing or its version drifts # from client/cache-wasm/Cargo.toml (bump that version to push a rebuild to # dev machines). See tooling/xtask/crates/xtask_cache_wasm/src/main.rs. diff --git a/apps/web/load-agent-block.tmp.ts b/apps/web/load-agent-block.tmp.ts new file mode 100644 index 00000000000..3b508a97a2f --- /dev/null +++ b/apps/web/load-agent-block.tmp.ts @@ -0,0 +1,89 @@ +/** + * Headless visual check for the agent block with a mocked backend: the two + * DSS endpoints the block needs are served from a local session recording, + * so no docker stack is required. Temporary debug tooling — not shipped. + */ +import { readFileSync } from 'node:fs'; +import { chromium } from '@playwright/test'; + +const RECORDING = + process.argv[2] ?? + `${process.env.HOME}/.agent_runtime_sessions/0c5f554e-2fb0-496e-98cf-6b9e37755f45.jsonl`; +const SESSION_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeffff0001'; +const CHANNEL_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeffff0002'; +const URL = `http://localhost:3999/app/agent/${SESSION_ID}`; + +const entries = readFileSync(RECORDING, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const { direction, content } = JSON.parse(line); + return { direction, content }; + }); + +const session = { + id: SESSION_ID, + channelId: CHANNEL_ID, + botId: '00000000-0000-0000-0000-00000000a9e7', + model: 'claude-opus-5', + harness: 'claude-code', + repoUrl: 'https://github.com/macro/cloud-storage', + status: { kind: 'event', event: 'acp_ready' }, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), +}; + +const browser = await chromium.launch(); +const page = await browser.newPage({ + viewport: { width: 1280, height: 2000 }, +}); + +await page.route(`**/dss/agent-sessions/${SESSION_ID}`, (route) => + route.fulfill({ json: session }) +); +await page.route(`**/dss/agent-sessions/channel/${CHANNEL_ID}/log`, (route) => + route.fulfill({ + json: { agentSessionId: SESSION_ID, entries }, + }) +); + +page.on('pageerror', (err) => { + console.log('[pageerror]', String(err).slice(0, 1000)); +}); +page.on('console', (msg) => { + if (msg.type() === 'error' && !msg.text().includes('CONNECTION_REFUSED')) { + console.log('[console.error]', msg.text().slice(0, 300)); + } +}); + +console.log('navigating to', URL, `(${entries.length} entries)`); +await page.goto(URL, { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(20_000); + +const scrollTo = Number(process.argv[3] ?? 0); +if (scrollTo > 0) { + await page.evaluate((y) => { + const scroller = document.querySelector( + '[data-block-type="agent"] [class*="overflow"]' + ); + for (const el of document.querySelectorAll('div')) { + if (el.scrollHeight > el.clientHeight + 100) { + el.scrollTop = y; + break; + } + } + void scroller; + }, scrollTo); + await page.waitForTimeout(2_000); +} + +await page.screenshot({ + path: '/private/tmp/claude-501/-Users-eric-Code-macro/91f094aa-ce54-46ac-8cae-e6ec1f217a24/scratchpad/agent-block.png', + fullPage: false, +}); + +const text = await page.evaluate(() => document.body.innerText.slice(0, 1500)); +console.log('=== body text ==='); +console.log(text); + +await browser.close(); diff --git a/apps/web/package.json b/apps/web/package.json index 8ad882e6ff4..a3b3fe411af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "gen-graphql-cache-schema": "bun scripts/generate-graphql-cache-entity-resolver-schema.ts", "check-graphql-cache-schema": "bun scripts/generate-graphql-cache-entity-resolver-schema.ts --check", "gen-tools": "bun scripts/generate-dcs-tools.ts", + "gen-agent-fold-types": "just gen-agent-fold-types", "type-check": "tsc --noEmit --skipLibCheck --project tsconfig.json", "check": "bun check-graphql-cache-schema && bun type-check && bun biome check", "preview": "vite preview -c vite.config.ts --outDir dist --base /app", @@ -49,11 +50,11 @@ "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/playfair-display": "^5.2.8", "@fontsource-variable/roboto-mono": "^5.2.8", - "@inkibra/tauri-plugins": "git+https://github.com/macro-inc/tauri-plugins.git#6ddd6600e20436388f169e936b610fe93944b7b0", "@fullcalendar/core": "^6.1.21", "@fullcalendar/daygrid": "^6.1.21", "@fullcalendar/interaction": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21", + "@inkibra/tauri-plugins": "git+https://github.com/macro-inc/tauri-plugins.git#6ddd6600e20436388f169e936b610fe93944b7b0", "@kobalte/core": "^0.13.11", "@leeoniya/ufuzzy": "^1.0.19", "@lexical/history": "0.45.0", @@ -77,6 +78,7 @@ "@normy/query-core": "^0.21.0", "@opentelemetry/api": "^1.9.1", "@phosphor-icons/core": "^2.1.1", + "@pierre/diffs": "^1.3.5", "@solid-primitives/broadcast-channel": "^0.1.1", "@solid-primitives/context": "^0.3.2", "@solid-primitives/event-bus": "^1.1.2", @@ -111,6 +113,7 @@ "d3-shape": "^3.2.0", "date-fns": "^4.1.0", "detect-browser": "^5.3.0", + "diff": "^9.0.0", "dinero.js": "2.0.0-alpha.8", "email-validator": "^2.0.4", "emojibase-data": "^17.0.0", @@ -127,6 +130,7 @@ "pdfjs-dist": "github:macro-inc/pdf.js#v2.16.52-web", "posthog-js": "^1.387.0", "quill": "^2.0.3", + "shiki": "^4.4.3", "short-uuid": "^5.2.0", "signature_pad": "^5.0.10", "solid-custom-scrollbars": "^0.1.7", diff --git a/apps/web/src/features/block-agent/component/AgentMessage.tsx b/apps/web/src/features/block-agent/component/AgentMessage.tsx new file mode 100644 index 00000000000..0fe4fa1ffd9 --- /dev/null +++ b/apps/web/src/features/block-agent/component/AgentMessage.tsx @@ -0,0 +1,232 @@ +/** + * Renders one folded agent-session message from the block-agent ui library: + * prose and thoughts as before, tool calls as collapsible `ToolCard`s with + * detail-specific bodies (Pierre-rendered diffs, ANSI terminals, path lists). + * + * The block-scoped sibling of the channel's `FoldedContent`, sharing its pure + * helpers (`FoldedTerminal`, `FoldedPathList`, `FoldedOutput`) but not its + * message chrome. + */ + +import { FoldedOutput } from '@channel/Message/FoldedOutput'; +import { FoldedPathList } from '@channel/Message/FoldedPathList'; +import { FoldedTerminal } from '@channel/Message/FoldedTerminal'; +import { StaticMarkdown } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown'; +import { channelTheme } from '@core/component/LexicalMarkdown/theme'; +import Brain from '@phosphor/brain.svg'; +import FileText from '@phosphor/file-text.svg'; +import MagnifyingGlass from '@phosphor/magnifying-glass.svg'; +import PencilSimple from '@phosphor/pencil-simple.svg'; +import ShieldCheck from '@phosphor/shield-check.svg'; +import Terminal from '@phosphor/terminal.svg'; +import Wrench from '@phosphor/wrench.svg'; +import type { + FoldedMessage, + FoldedMessagePart, + ToolDetail, +} from '@service-agent-fold/generated/types'; +import { diffLines } from 'diff'; +import { For, type JSX, Show } from 'solid-js'; +import { match } from 'ts-pattern'; +import { DiffChanges, PierreDiff, ToolCard, ToolErrorCard } from '../ui'; + +/** Sum added/removed lines across a call's file diffs for the +/− badge. */ +function countDiffChanges( + diffs: { oldText?: string | null; newText: string }[] +) { + let additions = 0; + let deletions = 0; + for (const diff of diffs) { + for (const change of diffLines(diff.oldText ?? '', diff.newText)) { + if (change.added) additions += change.count ?? 0; + if (change.removed) deletions += change.count ?? 0; + } + } + return { additions, deletions }; +} + +/** Subtitle for a call that touched paths: the path, or how many. */ +function pathsSubtitle(paths: string[]): string | undefined { + if (paths.length === 0) return undefined; + if (paths.length === 1) return paths[0]; + return `${paths.length} files`; +} + +function ToolUse(props: { + part: Extract; +}) { + const failed = () => props.part.status === 'failed'; + + const card = (detail: ToolDetail): JSX.Element => + match(detail) + .with({ kind: 'terminal' }, (detail) => ( + } + title={props.part.label} + subtitle={detail.command ?? undefined} + status={props.part.status} + > + + {(output) => ( + + )} + + + )) + .with({ kind: 'edit' }, (detail) => { + const changes = countDiffChanges(detail.diffs); + return ( + } + title={props.part.label} + subtitle={pathsSubtitle(detail.diffs.map((diff) => diff.path))} + trailing={} + status={props.part.status} + > + 0}> + + + + ); + }) + .with( + { kind: 'read' }, + { kind: 'delete' }, + { kind: 'move' }, + (detail) => ( + } + title={props.part.label} + subtitle={pathsSubtitle(detail.paths)} + status={props.part.status} + > + 1}> + + + + ) + ) + .with({ kind: 'search' }, (detail) => ( + } + title={props.part.label} + subtitle={pathsSubtitle(detail.paths)} + status={props.part.status} + > + + {(output) => } + + + )) + .with({ kind: 'fetch' }, { kind: 'think' }, (detail) => ( + : } + title={props.part.label} + status={props.part.status} + > + + {(output) => } + + + )) + .with({ kind: 'other' }, (detail) => ( + } + title={props.part.label} + status={props.part.status} + > + + {(output) => } + + + )) + .exhaustive(); + + return ( + + } + > + {card(props.part.detail)} + + ); +} + +/** Whatever text a detail carries, for the failure card's body. */ +function outputOf(detail: ToolDetail): string | undefined { + if ('output' in detail && detail.output) return detail.output; + return undefined; +} + +function Thought(props: { text: string }) { + return ( +
+
+ + Thought +
+
+ {props.text} +
+
+ ); +} + +function Permission(props: { + part: Extract; +}) { + const outcome = () => { + const resolved = props.part.outcome; + if (!resolved) return undefined; + if (resolved.kind === 'cancelled') return 'Cancelled'; + const chosen = props.part.options.find( + (option) => option.id === resolved.optionId + ); + return chosen?.name ?? 'Answered'; + }; + + return ( + } + title="Permission requested" + trailing={ + + {(label) => {label()}} + + } + status="completed" + /> + ); +} + +function AgentMessagePart(props: { part: FoldedMessagePart }): JSX.Element { + return match(props.part) + .with({ kind: 'text' }, (part) => ( +
+ +
+ )) + .with({ kind: 'thought' }, (part) => ) + .with({ kind: 'tool_use' }, (part) => ) + .with({ kind: 'permission' }, (part) => ) + .exhaustive(); +} + +export function AgentMessage(props: { folded: FoldedMessage }) { + return ( +
+ + {(part) => } + +
+ ); +} diff --git a/apps/web/src/features/block-agent/component/Block.tsx b/apps/web/src/features/block-agent/component/Block.tsx new file mode 100644 index 00000000000..f10e011bd53 --- /dev/null +++ b/apps/web/src/features/block-agent/component/Block.tsx @@ -0,0 +1,51 @@ +import { useBlockId } from '@core/block'; +import { Scroll } from '@ui'; +import { For, Show } from 'solid-js'; + +import { useAgentSessionBlockQuery } from '../data/queries'; +import { AgentMessage } from './AgentMessage'; + +function statusLabel(status: { kind: string; event?: string }): string { + return status.kind === 'event' && status.event + ? `event: ${status.event}` + : status.kind; +} + +function AgentBlockContent(props: { sessionId: string }) { + const query = useAgentSessionBlockQuery(() => props.sessionId); + + return ( +
+ + {(data) => ( +
+
{data().session.harness}
+
+ {data().session.model} · {statusLabel(data().session.status)} +
+
+ )} +
+ +
+ +
+ Couldn't load this agent session. +
+
+ + {(message) => } + +
+
+
+ ); +} + +export default function BlockAgent() { + const blockId = useBlockId(); + + return ( + {(id) => } + ); +} diff --git a/apps/web/src/features/block-agent/data/queries.ts b/apps/web/src/features/block-agent/data/queries.ts new file mode 100644 index 00000000000..da2357351dd --- /dev/null +++ b/apps/web/src/features/block-agent/data/queries.ts @@ -0,0 +1,42 @@ +import { foldSession } from '@core/agent-fold/client'; +import { throwOnErr } from '@core/util/result'; +import type { FoldedMessage } from '@service-agent-fold/generated/types'; +import { storageServiceClient } from '@service-storage/client'; +import type { AgentSessionResponse } from '@service-storage/generated/schemas/agentSessionResponse'; +import { useQuery } from '@tanstack/solid-query'; +import type { Accessor } from 'solid-js'; + +export type AgentSessionBlockData = { + session: AgentSessionResponse; + messages: FoldedMessage[]; +}; + +export function agentSessionBlockQueryKey(sessionId: string): string[] { + return ['agent-session', 'block', sessionId]; +} + +/** + * One-shot load for the agent block: the session's metadata plus its whole + * log, folded once. No live follow — a running session just looks frozen + * until the block is reopened. + */ +export function useAgentSessionBlockQuery(sessionId: Accessor) { + return useQuery(() => { + const currentId = sessionId(); + return { + queryKey: agentSessionBlockQueryKey(currentId), + queryFn: async (): Promise => { + const session = await throwOnErr(() => + storageServiceClient.getAgentSession({ session_id: currentId }) + ); + const log = await throwOnErr(() => + storageServiceClient.getAgentChannelLog({ + channel_id: session.channelId, + }) + ); + const messages = await foldSession(currentId, log.entries); + return { session, messages }; + }, + }; + }); +} diff --git a/apps/web/src/features/block-agent/definition.ts b/apps/web/src/features/block-agent/definition.ts new file mode 100644 index 00000000000..77b0b5fc9d5 --- /dev/null +++ b/apps/web/src/features/block-agent/definition.ts @@ -0,0 +1,19 @@ +import { defineBlock, type ExtractLoadType, LoadErrors } from '@core/block'; +import { ok } from 'neverthrow'; +import { lazy } from 'solid-js'; + +export const definition = defineBlock({ + name: 'agent', + description: 'View an agent session', + component: lazy(() => import('./component/Block')), + liveTrackingEnabled: false, + async load(source, _intent) { + if (source.type === 'dss') { + return ok({ id: source.id }); + } + return LoadErrors.MISSING; + }, + accepted: {}, +}); + +export type AgentData = ExtractLoadType<(typeof definition)['load']>; diff --git a/apps/web/src/features/block-agent/ui/AnimatedNumber.tsx b/apps/web/src/features/block-agent/ui/AnimatedNumber.tsx new file mode 100644 index 00000000000..fa6ddcb0ae8 --- /dev/null +++ b/apps/web/src/features/block-agent/ui/AnimatedNumber.tsx @@ -0,0 +1,129 @@ +/** + * Odometer-style rolling number: each digit rides a vertical strip that spins + * to its new value when the number changes. + * + * Ported from opencode's `animated-number.tsx` / `animated-number.css` + * (github.com/sst/opencode, MIT © 2025 opencode), restyled with Tailwind + * utilities in place of the original CSS file. + */ +import { createEffect, createMemo, For, Index, on } from 'solid-js'; +import { createStore } from 'solid-js/store'; + +/** + * Three copies of 0-9 so a strip can overshoot in either direction before the + * transition-end handler snaps it back to the middle copy. + */ +const TRACK = Array.from({ length: 30 }, (_, index) => index % 10); + +/** Map an arbitrary strip position back onto its digit (0-9). */ +function normalize(value: number): number { + return ((value % 10) + 10) % 10; +} + +/** How many cells to travel from one digit to another in a fixed direction. */ +function spin(from: number, to: number, direction: 1 | -1): number { + if (from === to) return 0; + if (direction > 0) return (to - from + 10) % 10; + return -((from - to + 10) % 10); +} + +function Digit(props: { value: number; direction: 1 | -1 }) { + // Start on the middle copy of the track. + const [state, setState] = createStore({ + step: props.value + 10, + animating: false, + }); + let last = props.value; + + createEffect( + on( + () => props.value, + (next) => { + const delta = spin(last, next, props.direction); + last = next; + if (!delta) { + setState({ animating: false, step: next + 10 }); + return; + } + + setState('animating', true); + setState('step', (value) => value + delta); + }, + { defer: true } + ) + ); + + return ( + + { + // Snap back (without animating) to the equivalent middle-copy cell. + setState('animating', false); + setState('step', (value) => normalize(value) + 10); + }} + > + + {(digit) => ( + + {digit} + + )} + + + + ); +} + +/** A non-negative integer that rolls odometer-style when its value changes. */ +export function AnimatedNumber(props: { value: number; class?: string }) { + const target = createMemo(() => { + if (!Number.isFinite(props.value)) return 0; + return Math.max(0, Math.round(props.value)); + }); + + const [state, setState] = createStore({ + value: target(), + direction: 1 as 1 | -1, + }); + + createEffect( + on( + target, + (next) => { + if (next === state.value) return; + setState({ direction: next > state.value ? 1 : -1, value: next }); + }, + { defer: true } + ) + ); + + const label = createMemo(() => state.value.toString()); + // Least-significant digit first; rendered row-reverse so digit identity is + // stable when the number grows or shrinks a place. + const digits = createMemo(() => + Array.from(label(), (char) => { + const code = char.charCodeAt(0) - 48; + if (code < 0 || code > 9) return 0; + return code; + }).reverse() + ); + + return ( + + + + {(digit) => } + + + + ); +} diff --git a/apps/web/src/features/block-agent/ui/CountSummary.tsx b/apps/web/src/features/block-agent/ui/CountSummary.tsx new file mode 100644 index 00000000000..7c9b2366b9f --- /dev/null +++ b/apps/web/src/features/block-agent/ui/CountSummary.tsx @@ -0,0 +1,151 @@ +/** + * A running summary line like "3 files read, 2 searches": each count rolls + * via {@link AnimatedNumber}, items slide in/out as their counts hit zero, and + * plural suffixes ("search" → "searches") animate where the words allow it. + * + * Ported from opencode's `tool-count-summary.tsx` (`AnimatedCountList`) and + * `tool-count-label.tsx` (`AnimatedCountLabel`) (github.com/sst/opencode, + * MIT © 2025 opencode). The original `{{count}}` template interpolation is + * simplified to `${count} ${count === 1 ? one : other}` composition, and the + * CSS files are replaced with Tailwind utilities. + */ +import { createMemo, Index, Show } from 'solid-js'; +import { AnimatedNumber } from './AnimatedNumber'; + +/** One countable thing, with its singular and plural labels. */ +export interface CountItem { + /** Stable identity for the item across re-renders. */ + key: string; + count: number; + /** Label when count is exactly 1, e.g. "file read". */ + one: string; + /** Label otherwise, e.g. "files read". */ + other: string; +} + +const EASE = 'ease-[cubic-bezier(0.22,1,0.36,1)]'; + +/** Grid-column reveal: width, opacity, blur, and a small settle transform. */ +const REVEAL = + `inline-grid origin-left translate-y-[0.06em] scale-[0.985] items-baseline overflow-hidden opacity-0 blur-[2px] [grid-template-columns:0fr] transition-[grid-template-columns,opacity,filter,transform] duration-300 ${EASE} ` + + 'data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:blur-none data-[active=true]:[grid-template-columns:1fr] motion-reduce:transition-none'; + +/** The ", " between visible items, revealed by max-width. */ +const SEPARATOR = + 'inline-flex max-w-0 -translate-x-[0.08em] items-baseline overflow-hidden opacity-0 transition-[max-width,margin,opacity,transform] duration-200 ease-out ' + + 'data-[active=true]:mr-[0.45ch] data-[active=true]:max-w-[1ch] data-[active=true]:translate-x-0 data-[active=true]:opacity-100 motion-reduce:transition-none'; + +/** The animated plural suffix inside a label. */ +const SUFFIX = + `inline-grid -translate-x-[0.04em] overflow-hidden opacity-0 blur-[1px] [grid-template-columns:0fr] transition-[grid-template-columns,opacity,filter,transform] duration-[250ms] ${EASE} ` + + 'data-[active=true]:translate-x-0 data-[active=true]:opacity-100 data-[active=true]:blur-none data-[active=true]:[grid-template-columns:1fr] motion-reduce:transition-none'; + +/** Split two labels into their shared stem and the diverging tails. */ +function splitWords(one: string, other: string) { + const a = Array.from(one); + const b = Array.from(other); + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) i++; + return { + stem: a.slice(0, i).join(''), + oneTail: a.slice(i).join(''), + otherTail: b.slice(i).join(''), + }; +} + +/** + * "3 searches" with the count rolling and, when one label is a prefix of the + * other, the extra letters sliding in and out on pluralization. Labels that + * diverge mid-word ("file read" / "files read") swap without animating. + */ +function CountLabel(props: { count: number; one: string; other: string }) { + const singular = createMemo(() => Math.round(props.count) === 1); + const parts = createMemo(() => splitWords(props.one, props.other)); + const animatable = createMemo( + () => parts().oneTail === '' || parts().otherTail === '' + ); + // The one non-empty tail (constant text, so it can animate out gracefully). + const tail = createMemo(() => parts().oneTail || parts().otherTail); + const showTail = createMemo(() => + singular() ? parts().oneTail !== '' : parts().otherTail !== '' + ); + + return ( + + + {` ${ + singular() ? props.one : props.other + }`} + } + > + {` ${parts().stem}`} + + {tail()} + + + + ); +} + +/** + * The summary line. Items with a zero count collapse away (they stay mounted + * so they can animate back in); when nothing is visible the `fallback` text + * shows instead. + */ +export function CountSummary(props: { + items: CountItem[]; + fallback?: string; + class?: string; +}) { + const fallback = createMemo(() => props.fallback ?? ''); + const showEmpty = createMemo( + () => props.items.every((item) => item.count <= 0) && fallback().length > 0 + ); + + return ( + + + + {fallback()} + + + + + {(item, index) => { + const active = createMemo(() => item().count > 0); + const hasPrev = createMemo(() => { + for (let i = index - 1; i >= 0; i--) { + if (props.items[i].count > 0) return true; + } + return false; + }); + + return ( + <> + + , + + + + + + + + ); + }} + + + ); +} diff --git a/apps/web/src/features/block-agent/ui/DiffChanges.tsx b/apps/web/src/features/block-agent/ui/DiffChanges.tsx new file mode 100644 index 00000000000..5c656d42cd6 --- /dev/null +++ b/apps/web/src/features/block-agent/ui/DiffChanges.tsx @@ -0,0 +1,115 @@ +/** + * Additions/deletions badge for a diff: "+18 −6" by default, or a GitHub-style + * five-block proportional bar with the `bars` variant. + * + * Port of opencode's diff-changes — github.com/sst/opencode, MIT © 2025 + * opencode — adapted to Macro tokens and Tailwind. + */ + +import { createMemo, For, Match, Show, Switch } from 'solid-js'; + +const TOTAL_BLOCKS = 5; + +export interface DiffChangesProps { + additions: number; + deletions: number; + variant?: 'default' | 'bars'; + class?: string; +} + +/** + * How many of the five bar blocks to paint green / red / neutral, roughly + * proportional to the diff but capped so tiny diffs never look sweeping. + */ +function blockCounts(adds: number, dels: number) { + if (adds === 0 && dels === 0) { + return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS }; + } + + const total = adds + dels; + + if (total < 5) { + const added = adds > 0 ? 1 : 0; + const deleted = dels > 0 ? 1 : 0; + return { added, deleted, neutral: TOTAL_BLOCKS - added - deleted }; + } + + const ratio = adds > dels ? adds / dels : dels / adds; + let blocksForColors = TOTAL_BLOCKS; + + if (total < 20) { + blocksForColors = TOTAL_BLOCKS - 1; + } else if (ratio < 4) { + blocksForColors = TOTAL_BLOCKS - 1; + } + + const addedRaw = (adds / total) * blocksForColors; + const deletedRaw = (dels / total) * blocksForColors; + + let added = adds > 0 ? Math.max(1, Math.round(addedRaw)) : 0; + let deleted = dels > 0 ? Math.max(1, Math.round(deletedRaw)) : 0; + + // Cap bars based on actual change magnitude. + if (adds > 0 && adds <= 5) added = Math.min(added, 1); + if (adds > 5 && adds <= 10) added = Math.min(added, 2); + if (dels > 0 && dels <= 5) deleted = Math.min(deleted, 1); + if (dels > 5 && dels <= 10) deleted = Math.min(deleted, 2); + + if (added + deleted > blocksForColors) { + if (addedRaw > deletedRaw) { + added = blocksForColors - deleted; + } else { + deleted = blocksForColors - added; + } + } + + const neutral = Math.max(0, TOTAL_BLOCKS - added - deleted); + + return { added, deleted, neutral }; +} + +export function DiffChanges(props: DiffChangesProps) { + const variant = () => props.variant ?? 'default'; + const total = () => props.additions + props.deletions; + + /** Fill class per bar block: additions, then deletions, then neutral. */ + const blocks = createMemo(() => { + const counts = blockCounts(props.additions, props.deletions); + return [ + ...Array(counts.added).fill('fill-success'), + ...Array(counts.deleted).fill('fill-failure'), + ...Array(counts.neutral).fill('fill-ink-placeholder'), + ].slice(0, TOTAL_BLOCKS); + }); + + return ( + 0}> +
+ + + + + + {`+${props.additions}`} + {`−${props.deletions}`} + + +
+
+ ); +} diff --git a/apps/web/src/features/block-agent/ui/PierreDiff.tsx b/apps/web/src/features/block-agent/ui/PierreDiff.tsx new file mode 100644 index 00000000000..51b706438e4 --- /dev/null +++ b/apps/web/src/features/block-agent/ui/PierreDiff.tsx @@ -0,0 +1,210 @@ +/** + * Syntax-highlighted file diffs rendered with @pierre/diffs. + * + * Pure component: props in, JSX out. Each file gets a path header and a + * unified diff rendered by pierre's vanilla `FileDiff` component, which + * mounts a `` custom element (shadow DOM) inside our + * wrapper div and computes the diff from full before/after contents. + * + * Choices, for the record: + * - Entry point: `FileDiff.render({ oldFile, newFile })` — takes raw + * before/after contents directly (`oldFile: null` marks a new file), + * matching our wire shape. No patch parsing on our side. + * - No worker pool: pierre renders fine on the main thread via its shared + * shiki highlighter (JS regex engine, no wasm fetch). Tradeoff: shiki + * tokenization of big files runs on the main thread; the + * MAX_RENDER_LINES fallback bounds that cost. Wire a WorkerPoolManager + * (see opencode's pierre/worker.ts) only if profiling demands it. + * - Theme: pierre's built-in `pierre-light`/`pierre-dark` pair (its + * default). Macro flags dark mode via `html[data-theme-light="false"]` + * (see features/theme/signals/themeReactive.ts), so we watch that + * attribute and drive `setThemeType('light' | 'dark')` explicitly + * instead of pierre's OS-preference "system" mode. + */ + +import { + type FileDiffOptions, + FileDiff as PierreFileDiffInstance, + type ThemeTypes, +} from '@pierre/diffs'; +import { + createEffect, + createMemo, + createSignal, + For, + type JSX, + onCleanup, + Show, + untrack, +} from 'solid-js'; +import type { FileDiff } from './types'; + +/** + * Files whose before or after side exceeds this many lines get a plain + * placeholder instead of a rendered diff, keeping main-thread highlighting + * cost bounded. + */ +const MAX_RENDER_LINES = 2000; + +/** Options for pierre's diff instance: static, unified, header-less. */ +const DIFF_OPTIONS = { + diffStyle: 'unified', + diffIndicators: 'bars', + // We render our own path header per file. + disableFileHeader: true, + overflow: 'wrap', + hunkSeparators: 'line-info-basic', + lineDiffType: 'none', + expansionLineCount: 20, +} satisfies FileDiffOptions; + +/** + * CSS variables pierre reads inside its shadow DOM, pointed at Macro's + * typography tokens so the diff matches surrounding font-mono text-xs UI. + */ +const DIFF_STYLE_VARIABLES: JSX.CSSProperties = { + '--diffs-font-family': 'var(--font-mono)', + '--diffs-font-size': '0.75rem', + '--diffs-line-height': '18px', + '--diffs-tab-size': '2', + '--diffs-gap-block': '0', + '--diffs-min-number-column-width': '4ch', +}; + +function currentThemeType(): 'light' | 'dark' { + return document.documentElement.dataset.themeLight === 'false' + ? 'dark' + : 'light'; +} + +/** + * Macro's light/dark state as a signal, tracked off the + * `data-theme-light` attribute the theme feature keeps on ``. + */ +function createThemeType(): () => 'light' | 'dark' { + const [themeType, setThemeType] = createSignal(currentThemeType()); + const observer = new MutationObserver(() => setThemeType(currentThemeType())); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme-light'], + }); + onCleanup(() => observer.disconnect()); + return themeType; +} + +function lineCount(text: string): number { + if (text.length === 0) return 0; + return text.split('\n').length; +} + +/** Plain note shown in place of a diff body. */ +function DiffNote(props: { children: JSX.Element }) { + return ( +
+ {props.children} +
+ ); +} + +/** Mounts one pierre diff instance for a single file. */ +function PierreFileView(props: { + diff: FileDiff; + themeType: () => 'light' | 'dark'; +}) { + let container!: HTMLDivElement; + let instance: PierreFileDiffInstance | undefined; + const [failed, setFailed] = createSignal(false); + + onCleanup(() => { + instance?.cleanUp(); + instance = undefined; + }); + + // Content: (re)render whenever the file's path or texts change. + createEffect(() => { + if (failed()) return; + const name = props.diff.path; + const oldText = props.diff.oldText; + const newText = props.diff.newText; + try { + instance ??= new PierreFileDiffInstance({ + ...DIFF_OPTIONS, + themeType: untrack(props.themeType) satisfies ThemeTypes, + }); + instance.render({ + oldFile: oldText === null ? null : { name, contents: oldText }, + newFile: { name, contents: newText }, + containerWrapper: container, + }); + } catch { + // Pierre throws on inputs it cannot diff (e.g. identical contents + // slipping through); degrade to a note rather than a broken card. + instance?.cleanUp(); + instance = undefined; + setFailed(true); + } + }); + + // Theme: flip pierre's light/dark styles alongside Macro's theme. + createEffect(() => { + const themeType = props.themeType(); + instance?.setThemeType(themeType); + }); + + return ( + diff could not be rendered} + > +
+ + ); +} + +/** One file's block: path header plus diff body (or a size fallback). */ +function FileDiffBlock(props: { + diff: FileDiff; + themeType: () => 'light' | 'dark'; +}) { + const longestSide = createMemo(() => + Math.max(lineCount(props.diff.oldText ?? ''), lineCount(props.diff.newText)) + ); + + return ( +
+ + {props.diff.path} + + + large diff ({longestSide().toLocaleString()} lines) + + } + > + + +
+ ); +} + +/** + * Renders each file's before/after contents as a syntax-highlighted + * unified diff, one block per file. + */ +export function PierreDiff(props: { diffs: FileDiff[] }): JSX.Element { + const themeType = createThemeType(); + + return ( +
+ + {(diff) => } + +
+ ); +} diff --git a/apps/web/src/features/block-agent/ui/QuestionAnswers.tsx b/apps/web/src/features/block-agent/ui/QuestionAnswers.tsx new file mode 100644 index 00000000000..a41f80df20a --- /dev/null +++ b/apps/web/src/features/block-agent/ui/QuestionAnswers.tsx @@ -0,0 +1,36 @@ +/** + * Body of a "question" tool call: each question the agent asked, with the + * answer(s) the user chose underneath. Body-only — the caller supplies the + * card chrome. + * + * Port of the question renderer from opencode's message-part.tsx — + * github.com/sst/opencode, MIT © 2025 opencode — adapted to Macro tokens. + */ + +import { For } from 'solid-js'; +import type { AnsweredQuestion } from './types'; + +export interface QuestionAnswersProps { + questions: AnsweredQuestion[]; +} + +export function QuestionAnswers(props: QuestionAnswersProps) { + return ( +
+ + {(item) => ( +
+
{item.question}
+
+ {item.answers.length > 0 ? ( + item.answers.join(', ') + ) : ( + No answer + )} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/features/block-agent/ui/TextShimmer.tsx b/apps/web/src/features/block-agent/ui/TextShimmer.tsx new file mode 100644 index 00000000000..0af920a6d5d --- /dev/null +++ b/apps/web/src/features/block-agent/ui/TextShimmer.tsx @@ -0,0 +1,73 @@ +/** + * A sweeping gradient shimmer over a piece of text while `active`, crossfading + * back to plain text when it settles. + * + * Ported from opencode's `packages/ui/src/components/text-shimmer.tsx` + * (github.com/sst/opencode, MIT © 2025 opencode). Rewritten against Macro's + * tokens: the sweep reuses the global `magic-chip-shimmer` keyframes from + * `index.css` (which already handles `prefers-reduced-motion`) instead of + * opencode's CSS variable system. + */ + +import { createEffect, createSignal, onCleanup } from 'solid-js'; + +/** How long the crossfade between shimmering and plain text runs. */ +const SWAP_MS = 200; + +export interface TextShimmerProps { + text: string; + active: boolean; + class?: string; +} + +/** Two stacked copies of the text: a plain base and a shimmering overlay. */ +export function TextShimmer(props: TextShimmerProps) { + // Keep the sweep running while the shimmer layer fades out, so deactivation + // doesn't freeze the gradient mid-swipe. + const [run, setRun] = createSignal(props.active); + let timer: ReturnType | undefined; + + createEffect(() => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + if (props.active) { + setRun(true); + return; + } + timer = setTimeout(() => { + timer = undefined; + setRun(false); + }, SWAP_MS); + }); + + onCleanup(() => { + if (timer !== undefined) clearTimeout(timer); + }); + + return ( + + + + + ); +} diff --git a/apps/web/src/features/block-agent/ui/TodoList.css b/apps/web/src/features/block-agent/ui/TodoList.css new file mode 100644 index 00000000000..d8b56bb573d --- /dev/null +++ b/apps/web/src/features/block-agent/ui/TodoList.css @@ -0,0 +1,28 @@ +/* + * Keyframes for the TodoList in-progress dot. Ported from opencode's + * `--animate-pulse-scale` (github.com/sst/opencode, MIT © 2025 opencode). + */ + +@keyframes block-agent-todo-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(0.6); + opacity: 0.5; + } +} + +.block-agent-todo-dot { + animation: block-agent-todo-pulse 1.6s ease-in-out infinite; + transform-origin: center; + transform-box: fill-box; +} + +@media (prefers-reduced-motion: reduce) { + .block-agent-todo-dot { + animation: none; + } +} diff --git a/apps/web/src/features/block-agent/ui/TodoList.tsx b/apps/web/src/features/block-agent/ui/TodoList.tsx new file mode 100644 index 00000000000..55b82dab7fb --- /dev/null +++ b/apps/web/src/features/block-agent/ui/TodoList.tsx @@ -0,0 +1,92 @@ +/** + * The agent's plan as a checklist: a read-only checkbox visual per item, a + * pulsing dot while an item is in progress, and content that strikes through + * and mutes once an item is completed or cancelled. + * + * Ported from opencode's inner `TodoList` (`session-todo-dock.tsx`) and its + * historical `todowrite` card body (`message-part.tsx`) + * (github.com/sst/opencode, MIT © 2025 opencode). Their kobalte Checkbox is + * replaced with an inline visual, and their motion-based `TextStrikethrough` + * with a `text-decoration-color` transition. + */ +import { For } from 'solid-js'; +import { match } from 'ts-pattern'; +import type { TodoItem } from './types'; +import './TodoList.css'; + +function CheckMark() { + return ( + + ); +} + +function PulsingDot() { + return ( + + ); +} + +/** The read-only checkbox stand-in: checked, pulsing, or empty. */ +function TodoBox(props: { status: TodoItem['status'] }) { + return ( + + {match(props.status) + .with('completed', () => ) + .with('in_progress', () => ) + .otherwise(() => undefined)} + + ); +} + +/** A read-only rendering of the agent's todo list. */ +export function TodoList(props: { todos: TodoItem[] }) { + return ( +
+ + {(todo) => { + const struck = () => + todo.status === 'completed' || todo.status === 'cancelled'; + return ( +
+ + + {todo.content} + +
+ ); + }} +
+
+ ); +} diff --git a/apps/web/src/features/block-agent/ui/ToolCard.tsx b/apps/web/src/features/block-agent/ui/ToolCard.tsx new file mode 100644 index 00000000000..79e182d74c7 --- /dev/null +++ b/apps/web/src/features/block-agent/ui/ToolCard.tsx @@ -0,0 +1,122 @@ +/** + * The universal collapsible tool card: one row per tool call, with an optional + * expandable body. + * + * Ported from opencode's + * `packages/session-ui/src/v2/components/basic-tool-v2.tsx` + * (github.com/sst/opencode, MIT © 2025 opencode), restyled to Macro's tokens + * and the `Tool` card idiom. + */ + +import { Collapsible } from '@kobalte/core/collapsible'; +import CaretRight from '@phosphor/caret-right.svg'; +import { + createMemo, + For, + type JSX, + children as resolveChildren, + Show, +} from 'solid-js'; +import { TextShimmer } from './TextShimmer'; +import { isToolActive, type ToolStatus } from './types'; + +export interface ToolCardProps { + icon?: JSX.Element; + title: JSX.Element | string; + /** Mono, truncated detail next to the title (a path, a command, ...). */ + subtitle?: string; + /** Small `key=value` chips after the subtitle. */ + args?: Record; + /** Right-aligned slot before the chevron (status text, counts, ...). */ + trailing?: JSX.Element; + status: ToolStatus; + /** Controlled open state; omit to let the card manage its own. */ + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + /** Expandable body. Without children the row has no collapse affordance. */ + children?: JSX.Element; +} + +const ROW_CLASS = + 'flex min-h-9 w-full items-center gap-2 px-3 py-2 text-left text-xs leading-5'; + +export function ToolCard(props: ToolCardProps) { + const active = () => isToolActive(props.status); + const resolved = resolveChildren(() => props.children); + const hasChildren = createMemo(() => { + const body = resolved(); + return Array.isArray(body) ? body.length > 0 : body != null; + }); + + const row = (expandable: boolean) => ( + <> + + + {props.icon} + + + + + {typeof props.title === 'string' ? ( + + ) : ( + props.title + )} + + + {(subtitle) => ( + <> + + {subtitle()} + + )} + + + {([key, value]) => ( + + {key}={value} + + )} + + + + + {props.trailing} + + + + + + ); + + return ( +
+ {row(false)}
} + > + + + {row(true)} + + +
{resolved()}
+
+
+ +
+ ); +} diff --git a/apps/web/src/features/block-agent/ui/ToolErrorCard.tsx b/apps/web/src/features/block-agent/ui/ToolErrorCard.tsx new file mode 100644 index 00000000000..72e1d57313f --- /dev/null +++ b/apps/web/src/features/block-agent/ui/ToolErrorCard.tsx @@ -0,0 +1,135 @@ +/** + * Error card for a failed tool call: a red-edged collapsible row whose header + * is "Tool · Subtitle" and whose body is the rest of the error, with a + * copy-error button. + * + * Port of opencode's tool-error-card (v2 layout + v1 error parsing and copy + * button) — github.com/sst/opencode, MIT © 2025 opencode — adapted to Macro + * tokens and Tailwind. + */ + +import { Collapsible } from '@kobalte/core/collapsible'; +import CaretRight from '@phosphor/caret-right.svg'; +import Check from '@phosphor/check.svg'; +import Copy from '@phosphor/copy.svg'; +import Prohibit from '@phosphor/prohibit.svg'; +import { Button } from '@ui'; +import { createMemo, createSignal, Show } from 'solid-js'; + +/** Display names for well-known tool ids (from opencode's v1 card). */ +const TOOL_NAMES: Record = { + read: 'Read', + list: 'List', + glob: 'Glob', + grep: 'Grep', + task: 'Task', + webfetch: 'Webfetch', + websearch: 'Web Search', + bash: 'Shell', + shell: 'Shell', + patch: 'Patch', + apply_patch: 'Patch', + question: 'Questions', +}; + +export interface ToolErrorCardProps { + /** Tool id the error came from, e.g. "bash". */ + tool: string; + /** Raw error text, e.g. "Error: bash command failed: exit 1". */ + error: string; + /** Controlled expanded state; leave unset for uncontrolled. */ + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function ToolErrorCard(props: ToolErrorCardProps) { + const [copied, setCopied] = createSignal(false); + + const title = createMemo(() => TOOL_NAMES[props.tool] ?? props.tool); + + /** Error text minus any leading "Error: ". */ + const cleaned = createMemo(() => + props.error.replace(/^Error:\s*/, '').trim() + ); + + /** `cleaned` minus a leading " " echo of the tool's own name. */ + const tail = createMemo(() => { + const value = cleaned(); + const prefix = `${props.tool} `; + return value.startsWith(prefix) ? value.slice(prefix.length) : value; + }); + + /** First "head: rest" segment of the error, capitalized; "Failed" if none. */ + const subtitle = createMemo(() => { + const parts = tail().split(': '); + if (parts.length <= 1) return 'Failed'; + const head = (parts[0] ?? '').trim(); + if (!head) return 'Failed'; + return head[0].toUpperCase() + head.slice(1); + }); + + /** Everything after the subtitle; the whole error when there's no split. */ + const body = createMemo(() => { + const parts = tail().split(': '); + if (parts.length <= 1) return cleaned(); + return parts.slice(1).join(': ').trim() || cleaned(); + }); + + const copy = async () => { + const text = cleaned(); + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy to clipboard', err); + } + }; + + return ( + + + +
+ {title()} + + {subtitle()} + +
+
+ +
+
+ {body()} +
+ +
+
+
+ ); +} diff --git a/apps/web/src/features/block-agent/ui/ToolStatusTitle.tsx b/apps/web/src/features/block-agent/ui/ToolStatusTitle.tsx new file mode 100644 index 00000000000..58fff8055a7 --- /dev/null +++ b/apps/web/src/features/block-agent/ui/ToolStatusTitle.tsx @@ -0,0 +1,153 @@ +/** + * An animated swap between an in-progress title ("Running command…", + * shimmering) and its finished form ("Ran command") when `active` flips. + * When the two share a prefix of at least two characters, only the differing + * suffix morphs. + * + * Ported from opencode's + * `packages/session-ui/src/components/tool-status-title.tsx` + * (github.com/sst/opencode, MIT © 2025 opencode). The spring width animation + * is replaced with a plain CSS width transition, and the blur/translate + * flourishes are dropped in favor of a simple opacity crossfade. + */ + +import { + createEffect, + createMemo, + createSignal, + on, + onCleanup, + Show, +} from 'solid-js'; +import { TextShimmer } from './TextShimmer'; + +/** Grace period before the width lock is released and text flows naturally. */ +const SETTLE_MS = 400; + +/** Split two strings into their shared prefix and the differing tails. */ +function commonPrefix(active: string, done: string) { + const a = Array.from(active); + const b = Array.from(done); + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) i++; + return { + prefix: a.slice(0, i).join(''), + activeTail: a.slice(i).join(''), + doneTail: b.slice(i).join(''), + }; +} + +function measure(el: HTMLSpanElement | undefined): string | undefined { + if (!el) return undefined; + return `${Math.ceil(el.getBoundingClientRect().width)}px`; +} + +export interface ToolStatusTitleProps { + active: boolean; + activeText: string; + doneText: string; + class?: string; +} + +export function ToolStatusTitle(props: ToolStatusTitleProps) { + const split = createMemo(() => + commonPrefix(props.activeText, props.doneText) + ); + const useSuffix = createMemo( + () => + split().prefix.length >= 2 && + split().activeTail.length > 0 && + split().doneTail.length > 0 + ); + const activeTail = createMemo(() => + useSuffix() ? split().activeTail : props.activeText + ); + const doneTail = createMemo(() => + useSuffix() ? split().doneTail : props.doneText + ); + + // What's currently displayed; trails props.active by one effect run so the + // outgoing width can be measured before the swap. + const [shown, setShown] = createSignal(props.active); + const [animating, setAnimating] = createSignal(false); + const [width, setWidth] = createSignal(undefined); + + let tailRef: HTMLSpanElement | undefined; + let activeRef: HTMLSpanElement | undefined; + let doneRef: HTMLSpanElement | undefined; + let frame: number | undefined; + let settle: ReturnType | undefined; + + const finish = () => { + if (frame !== undefined) cancelAnimationFrame(frame); + if (settle !== undefined) clearTimeout(settle); + frame = undefined; + settle = undefined; + setAnimating(false); + setWidth(undefined); + }; + + createEffect( + on( + [() => props.active, activeTail, doneTail], + ([next]) => { + const from = measure(tailRef); + finish(); + setShown(next); + if (!from) return; + setAnimating(true); + setWidth(from); + frame = requestAnimationFrame(() => { + frame = undefined; + const to = measure(next ? activeRef : doneRef); + if (!to) { + finish(); + return; + } + setWidth(to); + settle = setTimeout(finish, SETTLE_MS); + }); + }, + { defer: true } + ) + ); + + onCleanup(finish); + + return ( + + + + + + + + + + + + + + + {doneTail()} + + + + + ); +} diff --git a/apps/web/src/features/block-agent/ui/index.ts b/apps/web/src/features/block-agent/ui/index.ts new file mode 100644 index 00000000000..9d8475cc384 --- /dev/null +++ b/apps/web/src/features/block-agent/ui/index.ts @@ -0,0 +1,28 @@ +/** + * Pure component library for rendering agent sessions. + * + * Everything here is props-in, JSX-out — no contexts, no queries. Several + * components are ports from opencode (github.com/sst/opencode, MIT © 2025 + * opencode); see individual file headers. + */ + +export { AnimatedNumber } from './AnimatedNumber'; +export { type CountItem, CountSummary } from './CountSummary'; +export { DiffChanges, type DiffChangesProps } from './DiffChanges'; +export { PierreDiff } from './PierreDiff'; +export { QuestionAnswers, type QuestionAnswersProps } from './QuestionAnswers'; +export { TextShimmer, type TextShimmerProps } from './TextShimmer'; +export { TodoList } from './TodoList'; +export { ToolCard, type ToolCardProps } from './ToolCard'; +export { ToolErrorCard, type ToolErrorCardProps } from './ToolErrorCard'; +export { + ToolStatusTitle, + type ToolStatusTitleProps, +} from './ToolStatusTitle'; +export { + type AnsweredQuestion, + type FileDiff, + isToolActive, + type TodoItem, + type ToolStatus, +} from './types'; diff --git a/apps/web/src/features/block-agent/ui/types.ts b/apps/web/src/features/block-agent/ui/types.ts new file mode 100644 index 00000000000..68dd29a1917 --- /dev/null +++ b/apps/web/src/features/block-agent/ui/types.ts @@ -0,0 +1,34 @@ +/** + * Shared vocabulary for the block-agent UI component library. + * + * Components in this directory are pure: props in, JSX out. No contexts, no + * queries, no block signals. Data shapes come from the agent-fold wire types + * where one exists; anything else is declared here. + * + * Several components are ports of opencode's session-ui/ui packages + * (github.com/sst/opencode, MIT © 2025 opencode), adapted to Macro's tokens + * and the agent-fold vocabulary. + */ + +import type { ToolStatus } from '@service-agent-fold/generated/types'; + +export type { FileDiff, ToolStatus } from '@service-agent-fold/generated/types'; + +/** One entry of an agent's plan / todo list. */ +export interface TodoItem { + /** What the agent said it would do. */ + content: string; + status: 'pending' | 'in_progress' | 'completed' | 'cancelled'; +} + +/** One question the agent asked the user, with the answer(s) chosen. */ +export interface AnsweredQuestion { + question: string; + /** Empty when the question went unanswered. */ + answers: string[]; +} + +/** Whether a tool call is still in flight. */ +export function isToolActive(status: ToolStatus): boolean { + return status === 'pending' || status === 'running'; +} diff --git a/apps/web/src/features/channel/Channel/tests/hide-duplicate-prompts.test.ts b/apps/web/src/features/channel/Channel/tests/hide-duplicate-prompts.test.ts index 31e00c23f57..87113c67599 100644 --- a/apps/web/src/features/channel/Channel/tests/hide-duplicate-prompts.test.ts +++ b/apps/web/src/features/channel/Channel/tests/hide-duplicate-prompts.test.ts @@ -1,5 +1,5 @@ -import type { FoldedMessage } from '@core/agent-fold/types'; import type { FoldedMessageLookup } from '@queries/channel/folded-messages'; +import type { FoldedMessage } from '@service-agent-fold/generated/types'; import type { ApiAgentSessionMessageIdentifier } from '@service-storage/generated/schemas/apiAgentSessionMessageIdentifier'; import { describe, expect, it } from 'vitest'; import { duplicatePromptRowIds } from '../hide-duplicate-prompts'; @@ -14,8 +14,10 @@ function folded( return { agentSessionId: SESSION, turn, - author: kind === 'user' ? { kind: 'user' } : { kind: 'agent' }, + author: + kind === 'user' ? { kind: 'user', userId: null } : { kind: 'agent' }, parts: [{ kind: 'text', text }], + stop: null, }; } diff --git a/apps/web/src/features/channel/Message/FoldedAnsiText.tsx b/apps/web/src/features/channel/Message/FoldedAnsiText.tsx new file mode 100644 index 00000000000..a67697e2dc0 --- /dev/null +++ b/apps/web/src/features/channel/Message/FoldedAnsiText.tsx @@ -0,0 +1,105 @@ +import { createMemo, For } from 'solid-js'; + +/** One run of text sharing the same SGR styling. */ +interface AnsiSegment { + text: string; + fg?: string; + bold?: boolean; + dim?: boolean; +} + +/** Any CSI sequence, not just SGR - so cursor-movement codes strip too. */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ESC (0x1b) is the point +const CSI_PATTERN = /\x1b\[([0-9;]*)([A-Za-z])/g; + +/** + * Standard 16-color ANSI foreground palette. Deliberately fixed rather than + * drawn from Macro's own semantic tokens (accent/success/failure/...): a + * tool colored its output on purpose, and that palette means something + * different from Macro's UI palette - conflating them would misrepresent + * what the tool actually printed. + */ +const FG_COLORS: Record = { + 30: '#5c5c5c', + 31: '#e5484d', + 32: '#30a46c', + 33: '#f5a623', + 34: '#3b82f6', + 35: '#d6409f', + 36: '#12a4ac', + 37: '#e5e5e5', + 90: '#6b7280', + 91: '#f87171', + 92: '#4ade80', + 93: '#fbbf24', + 94: '#60a5fa', + 95: '#e879f9', + 96: '#22d3ee', + 97: '#ffffff', +}; + +/** + * Split ANSI-escaped text into styled runs. + * + * Only SGR color/weight codes (0, 1, 2, 22, 39, 30-37, 90-97) affect output; + * every other CSI sequence (cursor movement, screen clearing) is stripped + * without effect, since this renders a static log rather than a live + * terminal. + */ +function parseAnsi(input: string): AnsiSegment[] { + const segments: AnsiSegment[] = []; + let fg: string | undefined; + let bold = false; + let dim = false; + let lastIndex = 0; + + for (const match of input.matchAll(CSI_PATTERN)) { + const text = input.slice(lastIndex, match.index); + if (text) segments.push({ text, fg, bold, dim }); + lastIndex = match.index + match[0].length; + + if (match[2] !== 'm') continue; + const codes = match[1].length ? match[1].split(';').map(Number) : [0]; + for (const code of codes) { + if (code === 0) { + fg = undefined; + bold = false; + dim = false; + } else if (code === 1) bold = true; + else if (code === 2) dim = true; + else if (code === 22) { + bold = false; + dim = false; + } else if (code === 39) fg = undefined; + else if (code in FG_COLORS) fg = FG_COLORS[code]; + } + } + + const rest = input.slice(lastIndex); + if (rest) segments.push({ text: rest, fg, bold, dim }); + return segments; +} + +/** + * Renders ANSI-colored terminal output as styled spans, never `innerHTML` - + * every segment's text is a plain child, so this is safe over untrusted + * agent/tool output the same way any other text interpolation is. + */ +export function FoldedAnsiText(props: { text: string }) { + const segments = createMemo(() => parseAnsi(props.text)); + return ( + + {(segment) => ( + + {segment.text} + + )} + + ); +} diff --git a/apps/web/src/features/channel/Message/FoldedContent.tsx b/apps/web/src/features/channel/Message/FoldedContent.tsx index 7dd37215aea..0089baa5885 100644 --- a/apps/web/src/features/channel/Message/FoldedContent.tsx +++ b/apps/web/src/features/channel/Message/FoldedContent.tsx @@ -1,37 +1,128 @@ -import type { - FoldedMessage, - FoldedMessagePart, - ToolDetail, -} from '@core/agent-fold/types'; import { Tool } from '@core/component/AI/component/tool/Tool'; import { StaticMarkdown } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown'; import { channelTheme } from '@core/component/LexicalMarkdown/theme'; +import ArrowsLeftRight from '@phosphor/arrows-left-right.svg'; import Brain from '@phosphor/brain.svg'; import FileText from '@phosphor/file-text.svg'; +import Globe from '@phosphor/globe.svg'; +import MagnifyingGlass from '@phosphor/magnifying-glass.svg'; import PencilSimple from '@phosphor/pencil-simple.svg'; import ShieldCheck from '@phosphor/shield-check.svg'; import Terminal from '@phosphor/terminal.svg'; +import TrashSimple from '@phosphor/trash-simple.svg'; import Wrench from '@phosphor/wrench.svg'; -import { For, type JSX, Show } from 'solid-js'; +import type { + FoldedMessage, + FoldedMessagePart, + ToolDetail, +} from '@service-agent-fold/generated/types'; +import { createMemo, createSignal, For, type JSX, Show } from 'solid-js'; +import { match } from 'ts-pattern'; +import { FoldedDiff } from './FoldedDiff'; +import { FoldedOutput } from './FoldedOutput'; +import { FoldedPathList } from './FoldedPathList'; +import { FoldedTerminal } from './FoldedTerminal'; -/** Icon and one-line detail for a tool call, keyed off what the tool did. */ -function toolPresentation(detail: ToolDetail) { - switch (detail.kind) { - case 'terminal': - return { icon: Terminal, summary: detail.command ?? undefined }; - case 'edit': - return { - icon: PencilSimple, - summary: detail.diffs.map((diff) => diff.path).join(', ') || undefined, - }; - case 'read': +/** Paths shown inline before a call's row grows a "+N more" and a full list. */ +const INLINE_PATH_LIMIT = 3; + +/** A short, truncated stand-in for a full path list. */ +function pathSummary(paths: string[]): string | undefined { + if (paths.length === 0) return undefined; + if (paths.length <= INLINE_PATH_LIMIT) return paths.join(', '); + return `${paths.slice(0, INLINE_PATH_LIMIT).join(', ')} +${ + paths.length - INLINE_PATH_LIMIT + } more`; +} + +/** What a tool call's row shows, and what (if anything) hides behind its toggle. */ +interface ToolPresentation { + icon: (props: JSX.SvgSVGAttributes) => JSX.Element; + summary?: string; + status?: string; + body?: JSX.Element; +} + +/** + * Icon, inline summary, and expandable body for a tool call, keyed off what + * the tool did. + */ +function toolPresentation(detail: ToolDetail): ToolPresentation { + return match(detail) + .with({ kind: 'terminal' }, (detail) => ({ + icon: Terminal, + summary: detail.command ?? undefined, + body: detail.output ? ( + + ) : undefined, + })) + .with({ kind: 'edit' }, (detail) => ({ + icon: PencilSimple, + summary: + detail.diffs.length === 1 + ? detail.diffs[0].path + : detail.diffs.map((diff) => diff.path).join(', ') || undefined, + status: + detail.diffs.length > 1 ? `${detail.diffs.length} files` : undefined, + body: + detail.diffs.length > 0 ? ( + + ) : undefined, + })) + .with({ kind: 'read' }, { kind: 'delete' }, { kind: 'move' }, (detail) => ({ + icon: + detail.kind === 'read' + ? FileText + : detail.kind === 'delete' + ? TrashSimple + : ArrowsLeftRight, + summary: pathSummary(detail.paths), + body: + detail.paths.length > INLINE_PATH_LIMIT ? ( + + ) : undefined, + })) + .with({ kind: 'search' }, (detail) => { + const showList = detail.paths.length > INLINE_PATH_LIMIT; + const hasOutput = !!detail.output; return { - icon: FileText, - summary: detail.paths.join(', ') || undefined, + icon: MagnifyingGlass, + summary: pathSummary(detail.paths), + body: + showList || hasOutput ? ( +
+ + + + + {(output) => } + +
+ ) : undefined, }; - case 'other': - return { icon: Wrench, summary: undefined }; - } + }) + .with({ kind: 'fetch' }, { kind: 'think' }, (detail) => ({ + icon: detail.kind === 'fetch' ? Globe : Brain, + body: detail.output ? : undefined, + })) + .with({ kind: 'other' }, (detail) => ({ + icon: Wrench, + body: detail.output ? ( + + ) : detail.input !== undefined && detail.input !== null ? ( + + ) : undefined, + })) + .exhaustive(); +} + +/** Whether a tool call reported failure, ACP status or otherwise. */ +function toolFailed(part: Extract) { + if (part.status === 'failed') return true; + const detail = part.detail; + return ( + detail.kind === 'terminal' && !!detail.exitCode && detail.exitCode !== 0 + ); } /** What the user chose on a permission request, as a trailing label. */ @@ -63,48 +154,69 @@ function Thought(props: { text: string }) { ); } -// Folded parts are plain immutable query data — a new array arrives on each -// refetch — so rendering them non-reactively in a switch is safe. -function FoldedPart(props: { part: FoldedMessagePart }): JSX.Element { - const part = props.part; - switch (part.kind) { - case 'text': - return ( -
- ; +}) { + const [expanded, setExpanded] = createSignal(false); + const presentation = createMemo(() => toolPresentation(props.part.detail)); + const failed = createMemo(() => toolFailed(props.part)); + const hasBody = () => presentation().body !== undefined; + + return ( + + setExpanded((value) => !value)} + showToggle={hasBody()} + status={ + failed() ? ( + Failed + ) : ( + presentation().status + ) + } /> + } + > +
+ {props.part.label} + + {(summary) => ( + <> + · + {summary()} + + )} +
- ); - case 'thought': - return ; - case 'tool_use': { - const { icon: Icon, summary } = toolPresentation(part.detail); - const failed = part.status === 'failed'; - return ( - - Failed : undefined} - > -
- {part.label} - - {(summary) => ( - <> - · - {summary()} - - )} - -
-
-
- ); - } - case 'permission': { +
+ + {presentation().body} + +
+ ); +} + +// Folded parts are plain immutable query data — a new array arrives on each +// refetch — so rendering them non-reactively in a match is safe. +function FoldedPart(props: { part: FoldedMessagePart }): JSX.Element { + return match(props.part) + .with({ kind: 'text' }, (part) => ( +
+ +
+ )) + .with({ kind: 'thought' }, (part) => ) + .with({ kind: 'tool_use' }, (part) => ) + .with({ kind: 'permission' }, (part) => { const outcome = permissionOutcomeLabel(part); return ( @@ -118,8 +230,8 @@ function FoldedPart(props: { part: FoldedMessagePart }): JSX.Element { ); - } - } + }) + .exhaustive(); } /** diff --git a/apps/web/src/features/channel/Message/FoldedDiff.tsx b/apps/web/src/features/channel/Message/FoldedDiff.tsx new file mode 100644 index 00000000000..4a916f2c2a4 --- /dev/null +++ b/apps/web/src/features/channel/Message/FoldedDiff.tsx @@ -0,0 +1,121 @@ +import type { FileDiff } from '@service-agent-fold/generated/types'; +import { diffLines } from 'diff'; +import { createMemo, For, Show } from 'solid-js'; +import { match } from 'ts-pattern'; + +/** How many unchanged lines to keep around a change before collapsing. */ +const CONTEXT_LINES = 3; + +type DiffLine = { kind: 'add' | 'remove' | 'context'; text: string }; +type DiffRow = DiffLine | { kind: 'collapsed'; count: number }; + +/** Flatten `diffLines`' runs into one entry per line. */ +function toLines(oldText: string, newText: string): DiffLine[] { + const lines: DiffLine[] = []; + for (const change of diffLines(oldText, newText)) { + const kind = change.added ? 'add' : change.removed ? 'remove' : 'context'; + const value = change.value.endsWith('\n') + ? change.value.slice(0, -1) + : change.value; + if (value.length === 0) continue; + for (const text of value.split('\n')) lines.push({ kind, text }); + } + return lines; +} + +/** Collapse long unchanged runs to a few lines of context on each side. */ +function collapseContext(lines: DiffLine[]): DiffRow[] { + const rows: DiffRow[] = []; + let index = 0; + while (index < lines.length) { + if (lines[index].kind !== 'context') { + rows.push(lines[index]); + index++; + continue; + } + let end = index; + while (end < lines.length && lines[end].kind === 'context') end++; + const runLength = end - index; + const isFirstRun = index === 0; + const isLastRun = end === lines.length; + + if (runLength <= CONTEXT_LINES * 2) { + rows.push(...lines.slice(index, end)); + } else { + if (!isFirstRun) rows.push(...lines.slice(index, index + CONTEXT_LINES)); + const shown = + (isFirstRun ? 0 : CONTEXT_LINES) + (isLastRun ? 0 : CONTEXT_LINES); + rows.push({ kind: 'collapsed', count: runLength - shown }); + if (!isLastRun) rows.push(...lines.slice(end - CONTEXT_LINES, end)); + } + index = end; + } + return rows; +} + +function diffRows(diff: FileDiff): DiffRow[] { + return collapseContext(toLines(diff.oldText ?? '', diff.newText)); +} + +function DiffRowView(props: { row: DiffRow }) { + return match(props.row) + .with({ kind: 'collapsed' }, (row) => ( +
+ ⋯ {row.count} unchanged {row.count === 1 ? 'line' : 'lines'} +
+ )) + .with({ kind: 'add' }, (row) => ( +
+ + + + {row.text} + +
+ )) + .with({ kind: 'remove' }, (row) => ( +
+ - + + {row.text} + +
+ )) + .with({ kind: 'context' }, (row) => ( +
+ + + {row.text} + +
+ )) + .exhaustive(); +} + +function FileDiffBody(props: { diff: FileDiff }) { + const rows = createMemo(() => diffRows(props.diff)); + return ( +
+ {(row) => } +
+ ); +} + +/** One or more files an edit call reported, each as a unified diff. */ +export function FoldedDiff(props: { diffs: FileDiff[] }) { + return ( +
+ + {(diff) => ( +
+ 1}> + + {diff.path} + + + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/features/channel/Message/FoldedOutput.tsx b/apps/web/src/features/channel/Message/FoldedOutput.tsx new file mode 100644 index 00000000000..6d4d18fec18 --- /dev/null +++ b/apps/web/src/features/channel/Message/FoldedOutput.tsx @@ -0,0 +1,8 @@ +/** Free-form text a tool call reported - a search hit, a fetched page, a raw fallback. */ +export function FoldedOutput(props: { text: string }) { + return ( +
+      {props.text}
+    
+ ); +} diff --git a/apps/web/src/features/channel/Message/FoldedPathList.tsx b/apps/web/src/features/channel/Message/FoldedPathList.tsx new file mode 100644 index 00000000000..37664091bfa --- /dev/null +++ b/apps/web/src/features/channel/Message/FoldedPathList.tsx @@ -0,0 +1,18 @@ +import { Tool } from '@core/component/AI/component/tool/Tool'; +import FileText from '@phosphor/file-text.svg'; +import { For } from 'solid-js'; + +/** The full path list behind a truncated inline summary. */ +export function FoldedPathList(props: { paths: string[] }) { + return ( + + + {(path) => ( + }> + {path} + + )} + + + ); +} diff --git a/apps/web/src/features/channel/Message/FoldedTerminal.tsx b/apps/web/src/features/channel/Message/FoldedTerminal.tsx new file mode 100644 index 00000000000..7bce95895c7 --- /dev/null +++ b/apps/web/src/features/channel/Message/FoldedTerminal.tsx @@ -0,0 +1,19 @@ +import { Show } from 'solid-js'; +import { FoldedAnsiText } from './FoldedAnsiText'; + +/** A terminal call's output, ANSI-colored, plus its exit code when it failed. */ +export function FoldedTerminal(props: { + output: string; + exitCode?: number | null; +}) { + return ( +
+ + Exit code {props.exitCode} + +
+        
+      
+
+ ); +} diff --git a/apps/web/src/features/entity/utils/buildEntityData.ts b/apps/web/src/features/entity/utils/buildEntityData.ts index d419aaa9277..f792b8f13db 100644 --- a/apps/web/src/features/entity/utils/buildEntityData.ts +++ b/apps/web/src/features/entity/utils/buildEntityData.ts @@ -176,6 +176,8 @@ export function buildEntityData( .with('company', 'contact', (): undefined => undefined) // PRs are virtual blocks backed by GitHub, not Macro entities. .with('pr', (): undefined => undefined) + // Agent sessions aren't constructed from block args; not soup-listed yet. + .with('agent', (): undefined => undefined) .exhaustive() ); } diff --git a/apps/web/src/lib/core/agent-fold/client.ts b/apps/web/src/lib/core/agent-fold/client.ts index 48518c370e5..8e3a8e746cb 100644 --- a/apps/web/src/lib/core/agent-fold/client.ts +++ b/apps/web/src/lib/core/agent-fold/client.ts @@ -6,9 +6,12 @@ * channel, and the live sessions' machines all live in one place. */ +import type { + FoldedMessage, + FoldedMessageChange, +} from '@service-agent-fold/generated/types'; import type { AgentSessionLogEntryDto } from '@service-storage/generated/schemas/agentSessionLogEntryDto'; import type { FoldRequest, FoldResponse } from './protocol'; -import type { FoldedMessage, FoldedMessageChange } from './types'; interface Pending { resolve: (response: Extract) => void; diff --git a/apps/web/src/lib/core/agent-fold/fold.worker.ts b/apps/web/src/lib/core/agent-fold/fold.worker.ts index 11f16127cf2..10c36cc2354 100644 --- a/apps/web/src/lib/core/agent-fold/fold.worker.ts +++ b/apps/web/src/lib/core/agent-fold/fold.worker.ts @@ -15,8 +15,8 @@ * a `push` concurrently would fold frames out of order. */ +import type { FoldedMessageChange } from '@service-agent-fold/generated/types'; import type { FoldRequest, FoldResponse } from './protocol'; -import type { FoldedMessageChange } from './types'; import { type FoldStream, loadAgentFoldWasm } from './wasm-module'; const scope = self as unknown as DedicatedWorkerGlobalScope; diff --git a/apps/web/src/lib/core/agent-fold/protocol.ts b/apps/web/src/lib/core/agent-fold/protocol.ts index 422f15aec65..081d043947f 100644 --- a/apps/web/src/lib/core/agent-fold/protocol.ts +++ b/apps/web/src/lib/core/agent-fold/protocol.ts @@ -12,8 +12,11 @@ * same messages twice. */ +import type { + FoldedMessage, + FoldedMessageChange, +} from '@service-agent-fold/generated/types'; import type { AgentSessionLogEntryDto } from '@service-storage/generated/schemas/agentSessionLogEntryDto'; -import type { FoldedMessage, FoldedMessageChange } from './types'; /** * Open (or reopen) a session's machine and seed it with a fetched log. diff --git a/apps/web/src/lib/core/agent-fold/types.ts b/apps/web/src/lib/core/agent-fold/types.ts deleted file mode 100644 index 34a0f45da97..00000000000 --- a/apps/web/src/lib/core/agent-fold/types.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * The renderable vocabulary an agent session's protocol log folds into. - * - * These used to be generated from the backend's OpenAPI schema, because the - * fold ran on the server and the client was handed the result. It runs here - * now — `agent_fold` compiled to WASM, over the raw log from - * `getAgentChannelLog` — so the shapes are declared here instead, and the WASM - * boundary is what has to match them. - * - * They mirror `agent_fold::domain::model` (Rust) one for one, in the wire form - * the fold serializes to: enums are `kind`-tagged and fields are camelCase. - * Keeping the names identical on both sides is the point — the fold's - * vocabulary is the contract, not any particular transport. - */ - -/** How far a tool call progressed. */ -export type ToolStatus = 'pending' | 'running' | 'completed' | 'failed'; - -/** A file modification a tool reported. */ -export interface FileDiff { - /** The file that changed. */ - path: string; - /** Prior contents, absent when the file is new. */ - oldText?: string | null; - /** New contents. */ - newText: string; -} - -/** - * What a tool call actually did. - * - * Discriminated by what a reader needs in order to render it, not by ACP's - * tool kind: a terminal wants command and output, an edit wants a diff, and - * everything else falls back to its raw input. - */ -export type ToolDetail = - | { - kind: 'terminal'; - /** The command line, when the harness reported one. */ - command?: string | null; - /** Captured output, ANSI escape sequences left in place. */ - output?: string | null; - /** Process exit code, when the harness reported one. */ - exitCode?: number | null; - } - | { kind: 'edit'; diffs: FileDiff[] } - | { kind: 'read'; paths: string[] } - | { - kind: 'other'; - /** ACP's tool kind, as its wire string. */ - acpKind: string; - /** The tool's input, when reported. */ - input?: unknown; - }; - -/** One choice offered for a permission request. */ -export interface PermissionOption { - /** The id to report back when this option is chosen. */ - id: string; - /** Label to show. */ - name: string; - /** ACP's option kind: `allow_once`, `reject_once`, `allow_always`, `reject_always`. */ - kind: string; -} - -/** How a permission request resolved. */ -export type PermissionOutcome = - | { kind: 'selected'; optionId: string } - | { kind: 'cancelled' }; - -/** A unit of renderable content. */ -export type FoldedMessagePart = - | { kind: 'text'; text: string } - | { kind: 'thought'; text: string } - | { - kind: 'tool_use'; - /** The ACP `toolCallId`. */ - id: string; - /** What to show as the tool's name. */ - label: string; - status: ToolStatus; - detail: ToolDetail; - } - | { - kind: 'permission'; - /** The `toolCallId` permission was requested for. */ - toolCall: string; - /** The choices offered, in the order ACP listed them. */ - options: PermissionOption[]; - /** - * What the user chose. Absent while the request is outstanding, or when - * the session ended before anyone answered. - */ - outcome?: PermissionOutcome | null; - }; - -/** Who produced a folded message. */ -export type FoldedAuthor = - | { - kind: 'user'; - /** The user's macro id, absent when the prompt was unattributed. */ - userId?: string | null; - } - | { kind: 'agent' }; - -/** Why a turn stopped. */ -export type StopReason = - | { kind: 'end_turn' } - | { kind: 'max_tokens' } - | { kind: 'max_turn_requests' } - | { kind: 'refusal' } - | { kind: 'cancelled' } - | { kind: 'other'; reason: string }; - -/** - * One renderable message folded out of a session's protocol log. - * - * A turn produces at most two: the user's prompt and the agent's reply. - */ -export interface FoldedMessage { - /** Session that scopes this message id. */ - agentSessionId: string; - /** The turn within the session, assigned in log order from zero. */ - turn: number; - author: FoldedAuthor; - /** Ordered renderable content. Never empty. */ - parts: FoldedMessagePart[]; - /** - * How the turn ended, on the agent message that closed it. Absent while the - * turn is in flight or when the session died without a response. - */ - stop?: StopReason | null; -} - -/** - * What folding one more log frame changed. - * - * The message comes whole rather than as a delta, so either kind is applied - * the same way — replace whatever is held under this session, turn, and author. - * `kind` says whether a channel row for it exists yet: a turn has no - * placeholder message until the fold first derives it, and `'new'` is the one - * moment a client can synthesize one. - */ -export interface FoldedMessageChange { - kind: 'new' | 'update'; - message: FoldedMessage; -} diff --git a/apps/web/src/lib/core/agent-fold/wasm-module.ts b/apps/web/src/lib/core/agent-fold/wasm-module.ts index c0b514b726c..fed79ce277c 100644 --- a/apps/web/src/lib/core/agent-fold/wasm-module.ts +++ b/apps/web/src/lib/core/agent-fold/wasm-module.ts @@ -8,8 +8,11 @@ * src/lib/core/agent-fold/wasm/ (gitignored). */ +import type { + FoldedMessage, + FoldedMessageChange, +} from '@service-agent-fold/generated/types'; import type { AgentSessionLogEntryDto } from '@service-storage/generated/schemas/agentSessionLogEntryDto'; -import type { FoldedMessage, FoldedMessageChange } from './types'; /** * One live session's fold, held open between frames. diff --git a/apps/web/src/lib/core/block.ts b/apps/web/src/lib/core/block.ts index 3f9b2eeb98b..c388d90620c 100644 --- a/apps/web/src/lib/core/block.ts +++ b/apps/web/src/lib/core/block.ts @@ -65,6 +65,7 @@ export const BlockRegistry = [ 'company', 'automation', 'pr', + 'agent', ] as const; /** Block names that resolve through another concrete block implementation. */ @@ -108,6 +109,7 @@ export const NonDocumentBlockTypes = [ 'company', 'automation', 'pr', + 'agent', ] as const as (BlockName | BlockAlias)[]; /** @@ -164,6 +166,7 @@ const _ValidBlockCombinations: BlockCombinationRules = { automation: allBlockNames, csv: allBlockNames, pr: allBlockNames, + agent: allBlockNames, } as const; // maps block name to valid parents @@ -189,6 +192,7 @@ export const ValidNestingCombinations: BlockCombinationRules = { automation: new Set([]), csv: new Set([]), pr: new Set([]), + agent: new Set([]), }; export const LoadErrors = { diff --git a/apps/web/src/lib/core/blockMethodRegistry.ts b/apps/web/src/lib/core/blockMethodRegistry.ts index 970d2d44367..d4a3f175e6a 100644 --- a/apps/web/src/lib/core/blockMethodRegistry.ts +++ b/apps/web/src/lib/core/blockMethodRegistry.ts @@ -39,6 +39,7 @@ export interface BlockMethodRegistry { task: EmptySpec; automation: EmptySpec; pr: EmptySpec; + agent: EmptySpec; } // Type helper to get the method spec for a block name diff --git a/apps/web/src/lib/core/component/EntityIcon.tsx b/apps/web/src/lib/core/component/EntityIcon.tsx index 196f6b9e748..544291d88b9 100644 --- a/apps/web/src/lib/core/component/EntityIcon.tsx +++ b/apps/web/src/lib/core/component/EntityIcon.tsx @@ -59,6 +59,7 @@ import FolderUser from '@phosphor/folder-user.svg'; import GlobeIcon from '@phosphor/globe.svg'; import FileImage from '@phosphor/image.svg'; import Canvas from '@phosphor/pencil-circle.svg'; +import Robot from '@phosphor/robot.svg'; import Users from '@phosphor/users.svg'; import type { PreviewItem } from '@queries/preview'; import type { ChannelType } from '@service-cognition/generated/schemas/channelType'; @@ -273,6 +274,12 @@ export const ENTITY_ICON_CONFIGS: Record = { background: 'bg-default/20', prettyName: 'Pull Request', }, + agent: { + icon: Robot, + foreground: 'text-default', + background: 'bg-default/20', + prettyName: 'Agent', + }, task: { icon: Check, foreground: 'text-task', @@ -375,6 +382,7 @@ const WIDE_ICONS: Record< emailInvite: WideCalendar, githubPullRequest: GithubIcon, pr: GithubIcon, + agent: Robot, task: WideTask, snippet: WideSnippet, skill: SkillIcon, diff --git a/apps/web/src/lib/core/component/FileList/constants.tsx b/apps/web/src/lib/core/component/FileList/constants.tsx index 763336b2ec1..5070f16de5d 100644 --- a/apps/web/src/lib/core/component/FileList/constants.tsx +++ b/apps/web/src/lib/core/component/FileList/constants.tsx @@ -79,4 +79,5 @@ const _fileTypeColors: Record = { skill: defaultFileColor, automation: defaultFileColor, pr: defaultFileColor, + agent: defaultFileColor, }; diff --git a/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/create-magic-chip-model.ts b/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/create-magic-chip-model.ts index 7c72fd809e8..eccd3766cdd 100644 --- a/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/create-magic-chip-model.ts +++ b/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/create-magic-chip-model.ts @@ -1,11 +1,11 @@ import { type MessageId, withAuthor } from '@core/agent-fold/message-id'; -import type { FoldedMessage } from '@core/agent-fold/types'; import type { MagicChipDecoratorProps } from '@macro-inc/lexical-core'; import { subscribeAgentSessionLog } from '@queries/channel/agent-session-stream'; import { createFoldedMessages, type FoldedMessageLookup, } from '@queries/channel/folded-messages'; +import type { FoldedMessage } from '@service-agent-fold/generated/types'; import type { AgentSessionLogEntryDto } from '@service-storage/generated/schemas/agentSessionLogEntryDto'; import { type Accessor, createSignal, onCleanup } from 'solid-js'; import { diff --git a/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/presentation.test.ts b/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/presentation.test.ts index 97503248797..91bc4302b86 100644 --- a/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/presentation.test.ts +++ b/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/presentation.test.ts @@ -1,4 +1,4 @@ -import type { FoldedMessage } from '@core/agent-fold/types'; +import type { FoldedMessage } from '@service-agent-fold/generated/types'; import { describe, expect, it } from 'vitest'; import { deriveMagicChipPresentation } from './presentation'; @@ -7,6 +7,7 @@ const response = (overrides: Partial = {}): FoldedMessage => ({ turn: 0, author: { kind: 'agent' }, parts: [{ kind: 'thought', text: 'Inspecting the repository' }], + stop: null, ...overrides, }); @@ -46,7 +47,12 @@ describe('deriveMagicChipPresentation', () => { id: 'running', label: 'Terminal', status: 'running', - detail: { kind: 'terminal', command: 'cargo test' }, + detail: { + kind: 'terminal', + command: 'cargo test', + output: null, + exitCode: null, + }, }, { kind: 'tool_use', @@ -95,7 +101,12 @@ describe('deriveMagicChipPresentation', () => { id: 'running', label: 'Terminal', status: 'running', - detail: { kind: 'terminal', command: 'cargo test' }, + detail: { + kind: 'terminal', + command: 'cargo test', + output: null, + exitCode: null, + }, }, ], }), diff --git a/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/presentation.ts b/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/presentation.ts index 8bbfd90a9ff..1e283293d8f 100644 --- a/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/presentation.ts +++ b/apps/web/src/lib/core/component/LexicalMarkdown/component/decorator/MagicChip/presentation.ts @@ -1,5 +1,8 @@ -import type { FoldedMessage, FoldedMessagePart } from '@core/agent-fold/types'; import type { MagicChipStatus } from '@macro-inc/lexical-core'; +import type { + FoldedMessage, + FoldedMessagePart, +} from '@service-agent-fold/generated/types'; import { match } from 'ts-pattern'; export type MagicChipActivity = { @@ -62,7 +65,17 @@ function toolActivity( detail: detail.paths.at(-1) ?? part.label, busy, })) - .with({ kind: 'other' }, () => ({ + .with( + { kind: 'delete' }, + { kind: 'move' }, + { kind: 'search' }, + (detail) => ({ + label: failed ? `${part.label} failed` : part.label, + detail: detail.paths.at(-1) ?? part.label, + busy, + }) + ) + .with({ kind: 'fetch' }, { kind: 'think' }, { kind: 'other' }, () => ({ label: failed ? `${part.label} failed` : part.label, busy, })) diff --git a/apps/web/src/lib/queries/channel/agent-session-placeholders.ts b/apps/web/src/lib/queries/channel/agent-session-placeholders.ts index b07d3057e3f..897213bae2e 100644 --- a/apps/web/src/lib/queries/channel/agent-session-placeholders.ts +++ b/apps/web/src/lib/queries/channel/agent-session-placeholders.ts @@ -17,7 +17,7 @@ */ import { foldedReference, type MessageId } from '@core/agent-fold/message-id'; -import type { FoldedMessage } from '@core/agent-fold/types'; +import type { FoldedMessage } from '@service-agent-fold/generated/types'; import type { ApiChannelMessage } from '@service-storage/client'; import type { ApiMessageSender } from '@service-storage/generated/schemas/apiMessageSender'; import type { SessionBot } from '@service-storage/generated/schemas/sessionBot'; diff --git a/apps/web/src/lib/queries/channel/agent-session-stream.ts b/apps/web/src/lib/queries/channel/agent-session-stream.ts index ef380239d2a..9bf216f8d5b 100644 --- a/apps/web/src/lib/queries/channel/agent-session-stream.ts +++ b/apps/web/src/lib/queries/channel/agent-session-stream.ts @@ -47,7 +47,7 @@ import { type AgentSessionLogEvent, entryOf, } from '@core/agent-fold/stream-protocol'; -import type { FoldedMessage } from '@core/agent-fold/types'; +import type { FoldedMessage } from '@service-agent-fold/generated/types'; import type { AgentSessionLogEntryDto } from '@service-storage/generated/schemas/agentSessionLogEntryDto'; import { ensureAgentSessionPlaceholder } from './agent-session-placeholders'; diff --git a/apps/web/src/lib/queries/channel/folded-messages.ts b/apps/web/src/lib/queries/channel/folded-messages.ts index 39e4cd58295..6e2cab1f34c 100644 --- a/apps/web/src/lib/queries/channel/folded-messages.ts +++ b/apps/web/src/lib/queries/channel/folded-messages.ts @@ -1,6 +1,6 @@ import type { MessageId } from '@core/agent-fold/message-id'; -import type { FoldedMessage } from '@core/agent-fold/types'; import { throwOnErr } from '@core/util/result'; +import type { FoldedMessage } from '@service-agent-fold/generated/types'; import { storageServiceClient } from '@service-storage/client'; import type { AgentSessionLogEntryDto } from '@service-storage/generated/schemas/agentSessionLogEntryDto'; import { useQueryClient } from '@tanstack/solid-query'; diff --git a/apps/web/src/lib/service-clients/service-agent-fold/generated/types.ts b/apps/web/src/lib/service-clients/service-agent-fold/generated/types.ts new file mode 100644 index 00000000000..93a40d5793b --- /dev/null +++ b/apps/web/src/lib/service-clients/service-agent-fold/generated/types.ts @@ -0,0 +1,211 @@ +// This file has been generated by Specta. Do not edit this file manually. +/** A file modification a tool reported, mirroring [`crate::domain::model::FileDiff`]. */ +export type FileDiff = { + /** The file that changed. */ + path: string; + /** Prior contents, absent when the file is new. */ + oldText: string | null; + /** New contents. */ + newText: string; +}; + +/** Who produced a message, mirroring [`Author`]. */ +export type FoldedAuthor = + /** A person, via `session/prompt`. */ + | { + kind: 'user'; + /** Absent when the prompt was not attributed to anyone. */ + userId: string | null; + } + /** The agent. */ + | { kind: 'agent' }; + +/** One renderable message, mirroring [`crate::domain::model::FoldedMessage`]. */ +export type FoldedMessage = { + /** Session that scopes this message id. */ + agentSessionId: string; + /** The turn within the session, assigned in log order from zero. */ + turn: number; + author: FoldedAuthor; + /** Ordered renderable content. Never empty. */ + parts: FoldedMessagePart[]; + /** + * How the turn ended, on the agent message that closed it. Absent while + * the turn is in flight, or when the session died without a response. + */ + stop: StopReason | null; +}; + +/** + * What one pushed frame changed, mirroring [`IncrementalFoldResult`]. + * + * The message is carried whole rather than as a delta, so a reader applies + * either kind the same way - replace whatever it holds under this session, + * turn, and author. `kind` is what tells it whether a row for that id + * exists yet: a session streaming into a channel has no placeholder message + * for a turn until the fold first derives it, and `new` is the one moment + * the client can create one. + */ +export type FoldedMessageChange = { + /** `"new"` the first time a message is reported, `"update"` after. */ + kind: string; + message: FoldedMessage; +}; + +/** A unit of renderable content, mirroring [`MessagePart`]. */ +export type FoldedMessagePart = + /** Prose from the user or the agent. */ + | { + kind: 'text'; + /** The prose. */ + text: string; + } + /** The agent's reasoning, which a reader may want to hide by default. */ + | { + kind: 'thought'; + /** The reasoning. */ + text: string; + } + /** A tool the agent invoked. */ + | { + kind: 'tool_use'; + /** The ACP `toolCallId`. */ + id: string; + /** What to show as the tool's name. */ + label: string; + status: ToolStatus; + detail: ToolDetail; + } + /** The agent asking to proceed with a tool call. */ + | { + kind: 'permission'; + /** The `toolCallId` permission was requested for. */ + toolCall: string; + /** The choices offered, in the order ACP listed them. */ + options: PermissionOption[]; + /** + * What the user chose. Absent while the request is outstanding, or + * when it resolved into something the wire does not model - see + * [`permission_outcome`]. + */ + outcome: PermissionOutcome | null; + }; + +/** + * One choice offered for a permission request, mirroring + * [`crate::domain::model::PermissionOption`]. + */ +export type PermissionOption = { + /** The id to report back when this option is chosen. */ + id: string; + /** Label to show. */ + name: string; + /** ACP's option kind, as its wire string. */ + kind: string; +}; + +/** + * How a permission request resolved, mirroring + * [`crate::domain::model::PermissionOutcome`]. + */ +export type PermissionOutcome = + /** An option was chosen. */ + | { + kind: 'selected'; + /** The chosen option's id. */ + optionId: string; + } + /** The request was cancelled without a choice. */ + | { kind: 'cancelled' }; + +/** Why a turn stopped, mirroring [`crate::domain::model::StopReason`]. */ +export type StopReason = + | { kind: 'end_turn' } + | { kind: 'max_tokens' } + | { kind: 'max_turn_requests' } + | { kind: 'refusal' } + | { kind: 'cancelled' } + /** A stop reason this fold does not model, as its wire string. */ + | { + kind: 'other'; + /** The wire string. */ + reason: string; + }; + +/** What a tool call actually did, mirroring [`crate::domain::model::ToolDetail`]. */ +export type ToolDetail = + /** A shell command. */ + | { + kind: 'terminal'; + /** The command line, when the harness reported one. */ + command: string | null; + /** Captured output, ANSI escape sequences left in place. */ + output: string | null; + /** Process exit code, when the harness reported one. */ + exitCode: number | null; + } + /** One or more file modifications. */ + | { + kind: 'edit'; + /** The diffs ACP reported for this call. */ + diffs: FileDiff[]; + } + /** A file read. */ + | { + kind: 'read'; + /** Paths this call touched. */ + paths: string[]; + } + /** One or more files removed. */ + | { + kind: 'delete'; + /** Paths this call touched. */ + paths: string[]; + } + /** One or more files moved or renamed. */ + | { + kind: 'move'; + /** Paths this call touched. */ + paths: string[]; + } + /** A search. */ + | { + kind: 'search'; + /** Paths this call touched. */ + paths: string[]; + /** Text the call reported, when any. */ + output: string | null; + } + /** Retrieving external data. */ + | { + kind: 'fetch'; + /** Text the call reported, when any. */ + output: string | null; + } + /** Explicit reasoning surfaced as its own tool call. */ + | { + kind: 'think'; + /** Text the call reported, when any. */ + output: string | null; + } + /** Anything else, including tools the fold has no special rendering for. */ + | { + kind: 'other'; + /** ACP's tool kind, as its wire string. */ + acpKind: string; + /** Text the call reported, when any. */ + output: string | null; + /** + * The tool's input, when reported. + * + * Typed as `unknown` on the wire rather than a faithful JSON value: + * `serde_json::Value` can hold a `Number` wide enough to lose + * precision in `JSON.parse`, which specta refuses to export at all, + * and this is opaque, pass-through input a reader treats as such + * anyway. + */ + input: unknown; + }; + +/** How far a tool call progressed, mirroring [`crate::domain::model::ToolStatus`]. */ +export type ToolStatus = 'pending' | 'running' | 'completed' | 'failed'; diff --git a/apps/web/src/lib/service-clients/service-storage/client.ts b/apps/web/src/lib/service-clients/service-storage/client.ts index b9ae62e4018..3ec9e4ad92e 100644 --- a/apps/web/src/lib/service-clients/service-storage/client.ts +++ b/apps/web/src/lib/service-clients/service-storage/client.ts @@ -34,6 +34,7 @@ import type { AddFavoriteRequest } from './generated/schemas/addFavoriteRequest' import type { AddParticipantsRequest } from './generated/schemas/addParticipantsRequest'; import type { AddPinRequest } from './generated/schemas/addPinRequest'; import type { AgentChannelLogResponse } from './generated/schemas/agentChannelLogResponse'; +import type { AgentSessionResponse } from './generated/schemas/agentSessionResponse'; import type { AnchorResponse } from './generated/schemas/anchorResponse'; import type { ApiActivity } from './generated/schemas/apiActivity'; import type { ApiChannelAttachmentsPage } from './generated/schemas/apiChannelAttachmentsPage'; @@ -945,6 +946,15 @@ export const storageServiceClient = { ).map((result) => result); }, + async getAgentSession(args: { session_id: string }) { + const { session_id } = args; + return ( + await dssFetch(`/agent-sessions/${session_id}`, { + method: 'GET', + }) + ).map((result) => result); + }, + async getThreadReplies(args: WithChannelId & WithMessageId) { const { channel_id, message_id } = args; return ( diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index e8870b678ab..e58896627cb 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -128,6 +128,9 @@ "@service-properties/*": [ "./src/lib/service-clients/service-properties/*" ], + "@service-agent-fold/*": [ + "./src/lib/service-clients/service-agent-fold/*" + ], "@block-chat/*": [ "./src/features/block-chat/*" ], diff --git a/bun.lock b/bun.lock index 07ca0f17cfa..de425ca8e79 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "@normy/query-core": "^0.21.0", "@opentelemetry/api": "^1.9.1", "@phosphor-icons/core": "^2.1.1", + "@pierre/diffs": "^1.3.5", "@solid-primitives/broadcast-channel": "^0.1.1", "@solid-primitives/context": "^0.3.2", "@solid-primitives/event-bus": "^1.1.2", @@ -87,6 +88,7 @@ "d3-shape": "^3.2.0", "date-fns": "^4.1.0", "detect-browser": "^5.3.0", + "diff": "^9.0.0", "dinero.js": "2.0.0-alpha.8", "email-validator": "^2.0.4", "emojibase-data": "^17.0.0", @@ -103,6 +105,7 @@ "pdfjs-dist": "github:macro-inc/pdf.js#v2.16.52-web", "posthog-js": "^1.387.0", "quill": "^2.0.3", + "shiki": "^4.4.3", "short-uuid": "^5.2.0", "signature_pad": "^5.0.10", "solid-custom-scrollbars": "^0.1.7", @@ -943,7 +946,7 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@inkibra/tauri-plugins": ["@inkibra/tauri-plugins@github:macro-inc/tauri-plugins#6ddd660", { "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" } }, "macro-inc-tauri-plugins-6ddd660"], + "@inkibra/tauri-plugins": ["@inkibra/tauri-plugins@github:macro-inc/tauri-plugins#6ddd660", { "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" } }, "macro-inc-tauri-plugins-6ddd660", "sha512-7+0ebEOYqedkOY67Clwu65Np3qJ3aJc/H/NjNxmjgA/7HIu0C+DSoKAArG7HTdTVpdcAtHyhrqsJujdEAyJf6Q=="], "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], @@ -1263,6 +1266,12 @@ "@phosphor-icons/core": ["@phosphor-icons/core@2.1.1", "", {}, "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ=="], + "@pierre/diffs": ["@pierre/diffs@1.3.5", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-BhaLEiUvR+BdIyOYdogA4JLQjluWPubuwySmmIqEkcE0FwIWRbgJgHNC/r884dlxEr89fO4hTk/sBln0a7NSOw=="], + + "@pierre/theme": ["@pierre/theme@2.0.0", "", {}, "sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw=="], + + "@pierre/theming": ["@pierre/theming@1.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@playwright/test": ["@playwright/test@1.62.0", "", { "dependencies": { "playwright": "1.62.0" }, "bin": { "playwright": "cli.js" } }, "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA=="], @@ -1355,13 +1364,21 @@ "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="], - "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="], - "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="], - "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="], + + "@shikijs/transformers": ["@shikijs/transformers@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/types": "4.4.3" } }, "sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw=="], + + "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -1605,6 +1622,8 @@ "@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="], @@ -1633,6 +1652,8 @@ "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.6", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + "@urql/core": ["@urql/core@6.0.3", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.13", "wonka": "^6.3.2" } }, "sha512-0F39XWR21+IVLJfsqBvLShvoSzxT6h6wWXwrDhN8j9J+IEVOpQIUNHwpgjsp+2ePzn9aCH+rpDonn3HOAUnn9A=="], "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], @@ -1801,6 +1822,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001803", "", {}, "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], @@ -1811,6 +1834,10 @@ "change-case-all": ["change-case-all@2.1.0", "", { "dependencies": { "change-case": "^5.2.0", "sponge-case": "^2.0.2", "swap-case": "^3.0.2", "title-case": "^3.0.3" } }, "sha512-v6b0WWWkZUMHVuYk82l+WROgkUm4qEN2w5hKRNWtEOYwWqUGoi8C6xH0l1RLF1EoWqDFK6MFclmN3od6ws3/uw=="], + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], @@ -1841,6 +1868,8 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], @@ -1959,6 +1988,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], @@ -2235,12 +2266,18 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "hono": ["hono@4.12.29", "", {}, "sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw=="], "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "http2-client": ["http2-client@1.3.5", "", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="], @@ -2543,6 +2580,8 @@ "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -2557,6 +2596,8 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], @@ -2571,6 +2612,16 @@ "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], @@ -2651,6 +2702,10 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="], @@ -2721,7 +2776,7 @@ "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], - "pdfjs-dist": ["pdfjs-dist@github:macro-inc/pdf.js#f9b2ce6", { "dependencies": { "dommatrix": "^1.0.3", "web-streams-polyfill": "^3.2.1" }, "peerDependencies": { "worker-loader": "^3.0.8" }, "optionalPeers": ["worker-loader"] }, "macro-inc-pdf.js-f9b2ce6"], + "pdfjs-dist": ["pdfjs-dist@github:macro-inc/pdf.js#f9b2ce6", { "dependencies": { "dommatrix": "^1.0.3", "web-streams-polyfill": "^3.2.1" }, "peerDependencies": { "worker-loader": "^3.0.8" }, "optionalPeers": ["worker-loader"] }, "macro-inc-pdf.js-f9b2ce6", "sha512-HDFaPbCxLG2GHpf1smUG97nku5aPGBGaCcIbK05dGdp07TBLUlDcXsM4WcE82mO5W4RBPIBVYobX5N/6M6NFrA=="], "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], @@ -2753,6 +2808,8 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "proto3-json-serializer": ["proto3-json-serializer@2.0.2", "", { "dependencies": { "protobufjs": "^7.2.5" } }, "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ=="], "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], @@ -2811,6 +2868,12 @@ "reftools": ["reftools@1.1.9", "", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "remedial": ["remedial@1.0.8", "", {}, "sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg=="], @@ -2897,7 +2960,7 @@ "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], - "shiki": ["shiki@0.10.1", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-oniguruma": "^1.6.1", "vscode-textmate": "5.2.0" } }, "sha512-VsY7QJVzU51j5o1+DguUd+6vmCmZ5v/6gYu4vyYAhzjuNQU6P/vmSy4uQaOhvje031qQMiW0d2BwgMH52vqMng=="], + "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], "short-uuid": ["short-uuid@5.2.0", "", { "dependencies": { "any-base": "^1.1.0", "uuid": "^9.0.1" } }, "sha512-296/Nzi4DmANh93iYBwT4NoYRJuHnKEzefrkSagQbTH/A6NTaB68hSPDjm5IlbI5dx9FXdmtqPcj6N5H+CPm6w=="], @@ -2969,6 +3032,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "sponge-case": ["sponge-case@2.0.3", "", {}, "sha512-i4h9ZGRfxV6Xw3mpZSFOfbXjf0cQcYmssGWutgNIfFZ2VM+YIWfD71N/kjjwK6X/AAHzBr+rciEcn/L34S8TGw=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], @@ -3003,6 +3068,8 @@ "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3097,6 +3164,8 @@ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "ts-log": ["ts-log@3.0.2", "", {}, "sha512-esq6hx2lM66sQV1YcFkIYTqrWWabmqBqobKHyn1CswdI5FgfQhkmiKiRWVGBNlIbdjBxEIkNvMIwLKKPgRYZLQ=="], "ts-pattern": ["ts-pattern@5.9.0", "", {}, "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg=="], @@ -3149,6 +3218,16 @@ "unicode-emoji-json": ["unicode-emoji-json@0.8.0", "", {}, "sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="], @@ -3171,6 +3250,10 @@ "validator": ["validator@13.15.23", "", {}, "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "virtua": ["virtua@0.48.8", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ=="], "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], @@ -3279,6 +3362,8 @@ "zone.js": ["zone.js@0.16.2", "", {}, "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@antfu/install-pkg/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "@apidevtools/swagger-parser/@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@14.0.1", "", { "dependencies": { "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw=="], @@ -3309,6 +3394,14 @@ "@datadog/datadog-ci-plugin-synthetics/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + "@gerrit0/mini-shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@gerrit0/mini-shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@gerrit0/mini-shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@gerrit0/mini-shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "@google-cloud/common/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], "@google-cloud/common/html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], @@ -3643,6 +3736,8 @@ "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "typedoc/shiki": ["shiki@0.10.1", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-oniguruma": "^1.6.1", "vscode-textmate": "5.2.0" } }, "sha512-VsY7QJVzU51j5o1+DguUd+6vmCmZ5v/6gYu4vyYAhzjuNQU6P/vmSy4uQaOhvje031qQMiW0d2BwgMH52vqMng=="], + "typedoc-plugin-coverage/typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="], "typedoc-plugin-markdown/typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="], diff --git a/crates/agent_fold/Cargo.toml b/crates/agent_fold/Cargo.toml index 17793922e69..185fbff0734 100644 --- a/crates/agent_fold/Cargo.toml +++ b/crates/agent_fold/Cargo.toml @@ -10,9 +10,12 @@ crate-type = ["cdylib", "rlib"] [features] default = ["cli"] -# The `fold_jsonl` viewer and what only it needs. Off by default for wasm: -# `tokio` pulls in `mio`, which does not build for wasm32, and the library -# itself uses neither - the fold is sync and the ports are plain futures. +# The `fold_jsonl` viewer and the `export_types` codegen binary - native +# developer tooling, neither of which the wasm build needs. `fold_jsonl` needs +# `tokio`, which pulls in `mio`, which does not build for wasm32; +# `export_types` needs `std::fs` to write its output, which is pointless on a +# target with no filesystem. The library itself uses neither - the fold is +# sync, the ports are plain futures, and the browser only wants the schema. cli = ["dep:clap", "dep:tokio"] # In-memory port implementations and recorded fixtures, for crates that test # against this one. @@ -27,6 +30,11 @@ openapi = ["dep:utoipa"] name = "fold_jsonl" required-features = ["cli"] +# Needs a filesystem to write to, which the library does not. +[[bin]] +name = "export_types" +required-features = ["cli"] + [dependencies] agent-client-protocol = { workspace = true } # Schema only - the fold reads recorded frames and never opens a connection. @@ -41,6 +49,9 @@ non_empty = { path = "../non_empty" } rootcause = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +specta = { workspace = true, features = ["serde_json"] } +specta-serde = { workspace = true } +specta-typescript = { workspace = true } strum = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, optional = true } diff --git a/crates/agent_fold/src/bin/export_types.rs b/crates/agent_fold/src/bin/export_types.rs new file mode 100644 index 00000000000..be9f47a7ea3 --- /dev/null +++ b/crates/agent_fold/src/bin/export_types.rs @@ -0,0 +1,30 @@ +//! Export the fold's wire contract as TypeScript. +//! +//! Run with: +//! +//! ```text +//! cargo run -p agent_fold --bin export_types +//! ``` + +use agent_fold::inbound::wire::{FoldedMessage, FoldedMessageChange}; +use specta::Types; +use specta_typescript::Typescript; +use std::fs; +use std::path::Path; + +fn main() -> Result<(), Box> { + let types = Types::default() + .register::() + .register::(); + let output = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../apps/web/src/lib/service-clients/service-agent-fold/generated/types.ts"); + + let generated = Typescript::default().export(&types, specta_serde::Format)?; + let generated = generated + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + fs::write(output, format!("{generated}\n"))?; + Ok(()) +} diff --git a/crates/agent_fold/src/bin/fold_jsonl.rs b/crates/agent_fold/src/bin/fold_jsonl.rs index 093095f3f92..b69f5399335 100644 --- a/crates/agent_fold/src/bin/fold_jsonl.rs +++ b/crates/agent_fold/src/bin/fold_jsonl.rs @@ -251,13 +251,33 @@ fn render_tool(tool: &ToolUse) -> String { ); } } - ToolDetail::Read { paths } => { + ToolDetail::Read { paths } | ToolDetail::Delete { paths } | ToolDetail::Move { paths } => { for path in paths { let _ = writeln!(out, "{}", indent(&path.display().to_string())); } } - ToolDetail::Other { kind, input } => { + ToolDetail::Search { paths, output } => { + for path in paths { + let _ = writeln!(out, "{}", indent(&path.display().to_string())); + } + if let Some(output) = output { + let _ = writeln!(out, "{}", indent(output.trim_end())); + } + } + ToolDetail::Fetch { output } | ToolDetail::Think { output } => { + if let Some(output) = output { + let _ = writeln!(out, "{}", indent(output.trim_end())); + } + } + ToolDetail::Other { + kind, + output, + input, + } => { let _ = writeln!(out, "{}", indent(&format!("kind: {kind}"))); + if let Some(output) = output { + let _ = writeln!(out, "{}", indent(output.trim_end())); + } if let Some(input) = input { let json = serde_json::to_string_pretty(input).unwrap_or_else(|_| input.to_string()); diff --git a/crates/agent_fold/src/domain/fold.rs b/crates/agent_fold/src/domain/fold.rs index 2f73c71fb13..a7b1abebd3d 100644 --- a/crates/agent_fold/src/domain/fold.rs +++ b/crates/agent_fold/src/domain/fold.rs @@ -61,9 +61,10 @@ use crate::domain::model::{ }; use crate::domain::ports::{FoldMachine, FoldSession, LogRepo}; use agent_client_protocol::schema::v1::{ - ContentBlock, Meta, PromptRequest, RequestId, RequestPermissionOutcome, + Content, ContentBlock, Meta, PromptRequest, RequestId, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, Response, SessionNotification, - SessionUpdate, ToolCall, ToolCallContent, ToolCallStatus, ToolCallUpdate, ToolKind, + SessionUpdate, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate, + ToolKind, }; use agent_client_protocol::{JsonRpcMessage, RawJsonRpcMessage, RawJsonRpcParams}; use agent_runtime_protocol::domain::schema::v0::{ToRuntimeMessage, ToServerMessage}; @@ -489,6 +490,7 @@ impl FoldState { call.kind, call.raw_input.as_ref(), &call.content, + &call.locations, call.meta.as_ref(), ), }; @@ -552,6 +554,7 @@ impl FoldState { &mut tool.detail, fields.raw_input.as_ref(), fields.content.as_deref(), + fields.locations.as_deref(), update.meta.as_ref(), ); @@ -766,6 +769,7 @@ fn tool_detail( kind: ToolKind, raw_input: Option<&serde_json::Value>, content: &[ToolCallContent], + locations: &[ToolCallLocation], meta: Option<&Meta>, ) -> ToolDetail { match kind { @@ -778,10 +782,27 @@ fn tool_detail( diffs: diffs(content), }, ToolKind::Read => ToolDetail::Read { - paths: read_paths(raw_input), + paths: location_paths(locations), + }, + ToolKind::Delete => ToolDetail::Delete { + paths: location_paths(locations), + }, + ToolKind::Move => ToolDetail::Move { + paths: location_paths(locations), + }, + ToolKind::Search => ToolDetail::Search { + paths: location_paths(locations), + output: generic_output(content), + }, + ToolKind::Fetch => ToolDetail::Fetch { + output: generic_output(content), + }, + ToolKind::Think => ToolDetail::Think { + output: generic_output(content), }, other => ToolDetail::Other { kind: tool_kind_name(other).to_owned(), + output: generic_output(content), input: raw_input.cloned(), }, } @@ -793,6 +814,7 @@ fn patch_detail( detail: &mut ToolDetail, raw_input: Option<&serde_json::Value>, content: Option<&[ToolCallContent]>, + locations: Option<&[ToolCallLocation]>, meta: Option<&Meta>, ) { match detail { @@ -820,16 +842,35 @@ fn patch_detail( } } } - ToolDetail::Read { paths } => { - let found = read_paths(raw_input); - if !found.is_empty() { + ToolDetail::Read { paths } | ToolDetail::Delete { paths } | ToolDetail::Move { paths } => { + if let Some(found) = locations.map(location_paths) + && !found.is_empty() + { *paths = found; } } - ToolDetail::Other { input, .. } => { + ToolDetail::Search { paths, output } => { + if let Some(found) = locations.map(location_paths) + && !found.is_empty() + { + *paths = found; + } + if let Some(found) = content.and_then(generic_output) { + *output = Some(found); + } + } + ToolDetail::Fetch { output } | ToolDetail::Think { output } => { + if let Some(found) = content.and_then(generic_output) { + *output = Some(found); + } + } + ToolDetail::Other { input, output, .. } => { if let Some(found) = raw_input { *input = Some(found.clone()); } + if let Some(found) = content.and_then(generic_output) { + *output = Some(found); + } } } } @@ -849,13 +890,37 @@ fn diffs(content: &[ToolCallContent]) -> Vec { .collect() } -/// The paths a read tool was pointed at. -fn read_paths(raw_input: Option<&serde_json::Value>) -> Vec { - raw_input - .and_then(|input| input.get("file_path")) - .and_then(|path| path.as_str()) - .map(|path| vec![PathBuf::from(path)]) - .unwrap_or_default() +/// The paths among a tool call's reported locations. +/// +/// The one source this fold trusts for "what path did this call touch" - +/// `locations` is ACP's own field, meant for exactly this, unlike `rawInput`, +/// whose keys are a harness's own convention with no fixed shape to read. +fn location_paths(locations: &[ToolCallLocation]) -> Vec { + locations + .iter() + .map(|location| location.path.clone()) + .collect() +} + +/// The text among a tool call's content blocks - e.g. search matches or a +/// fetched page's text - joined in order. +/// +/// `None` when none of the blocks carry text, same as an empty result: there +/// is nothing useful to distinguish "reported nothing" from "reported an +/// empty string." +fn generic_output(content: &[ToolCallContent]) -> Option { + let text = content + .iter() + .filter_map(|block| match block { + ToolCallContent::Content(Content { + content: block_content, + .. + }) => content_block_text(block_content.clone()), + _ => None, + }) + .collect::>() + .join(""); + (!text.is_empty()).then_some(text) } fn permission_option_kind( diff --git a/crates/agent_fold/src/domain/model.rs b/crates/agent_fold/src/domain/model.rs index 756486fbf70..e94d18865f8 100644 --- a/crates/agent_fold/src/domain/model.rs +++ b/crates/agent_fold/src/domain/model.rs @@ -233,11 +233,15 @@ pub enum ToolStatus { /// /// Discriminated by what a reader needs in order to render it, not by ACP's /// [`ToolKind`](agent_client_protocol::ToolKind). A terminal wants command and -/// output; an edit wants a diff; everything else wants its raw input shown as -/// JSON. +/// output; an edit wants a diff; a handful of others want the paths they +/// touched or whatever text they reported; everything else wants its raw +/// input shown as JSON. Every named [`ToolKind`](agent_client_protocol::ToolKind) +/// has a variant here, so the fold never falls back to [`Self::Other`] for a +/// kind ACP defines - only for `switch_mode` (nothing a reader would want +/// rendered) and a kind this fold does not yet know about. #[derive(Debug, Clone, PartialEq)] pub enum ToolDetail { - /// A shell command. + /// A shell command. ACP's `execute`. Terminal { /// The command line, when the harness reported one. command: Option, @@ -246,20 +250,58 @@ pub enum ToolDetail { /// Process exit code, when the harness reported one. exit_code: Option, }, - /// One or more file modifications. + /// One or more file modifications. ACP's `edit`. Edit { /// The diffs ACP reported for this call. diffs: Vec, }, - /// A file read. + /// A file read. ACP's `read`. Read { /// Paths this call touched. paths: Vec, }, - /// Anything else, including tools the fold has no special rendering for. + /// One or more files removed. ACP's `delete`. + Delete { + /// Paths this call touched. + paths: Vec, + }, + /// One or more files moved or renamed. ACP's `move`. + /// + /// Only the paths a reader can be sure of. ACP has no standard field for + /// "from" versus "to" - a call's `locations` is just the set of paths it + /// touched - so this does not guess at a direction. + Move { + /// Paths this call touched. + paths: Vec, + }, + /// A search. ACP's `search`. + Search { + /// Paths this call touched. + paths: Vec, + /// Text the call reported - e.g. matched lines - when any. + output: Option, + }, + /// Retrieving external data. ACP's `fetch`. + Fetch { + /// Text the call reported, when any. + output: Option, + }, + /// Explicit reasoning surfaced as its own tool call, as distinct from + /// [`MessagePart::Thought`], which is reasoning streamed inline. ACP's + /// `think`. + Think { + /// Text the call reported, when any. + output: Option, + }, + /// Anything else: ACP's `switch_mode`, and any kind - including `other` + /// itself, [`ToolKind`](agent_client_protocol::ToolKind)'s default for a + /// call that names no kind at all - this fold has no special rendering + /// for. Other { /// ACP's tool kind, as its wire string. kind: String, + /// Text the call reported, when any. + output: Option, /// The tool's input, when reported. input: Option, }, diff --git a/crates/agent_fold/src/domain/test/fold.rs b/crates/agent_fold/src/domain/test/fold.rs index 581202513aa..1188cd2dc7d 100644 --- a/crates/agent_fold/src/domain/test/fold.rs +++ b/crates/agent_fold/src/domain/test/fold.rs @@ -149,6 +149,62 @@ fn folds_an_interrupted_turn() { ); } +/// Every ACP tool kind this fold has a bespoke rendering for, plus a call +/// with no kind at all - which ACP defaults to `other`, same as a kind this +/// fold does not model. +#[test] +fn folds_every_official_tool_kind() { + fn tool_call(id: &str, kind_and_fields: &str) -> String { + format!( + r#"{{"direction":"to_server","content":{{"type":"acp","jsonrpc":"2.0","method":"session/update","params":{{"sessionId":"s","update":{{"sessionUpdate":"tool_call","toolCallId":"{id}","title":"{id}","status":"completed",{kind_and_fields}}}}}}}}}"# + ) + } + + let log = parse_log(&[ + r#"{"direction":"to_runtime","content":{"type":"acp","jsonrpc":"2.0","id":"p","method":"session/prompt","params":{"sessionId":"s","prompt":[{"type":"text","text":"hi"}]}}}"#.to_owned(), + tool_call( + "del", + r#""kind":"delete","locations":[{"path":"/repo/dead.rs"}]"#, + ), + tool_call( + "mv", + r#""kind":"move","locations":[{"path":"/repo/from.rs"},{"path":"/repo/to.rs"}]"#, + ), + tool_call( + "search", + r#""kind":"search","locations":[{"path":"/repo/a.rs"}],"content":[{"type":"content","content":{"type":"text","text":"1 match"}}]"#, + ), + tool_call( + "fetch", + r#""kind":"fetch","content":[{"type":"content","content":{"type":"text","text":"page body"}}]"#, + ), + tool_call( + "think", + r#""kind":"think","content":[{"type":"content","content":{"type":"text","text":"reasoning aloud"}}]"#, + ), + tool_call( + "mystery", + r#""content":[{"type":"content","content":{"type":"text","text":"how should this work?"}}],"rawInput":{"foo":"bar"}"#, + ), + ].join("\n")); + + let (messages, warnings) = fold_capturing_warnings(log); + assert_eq!( + warnings, + vec![], + "every official kind folds without a warning" + ); + + let agent = &messages[1]; + assert_eq!( + agent.parts.len(), + 6, + "one part per tool call: {:#?}", + agent.parts + ); + insta::assert_debug_snapshot!(agent.parts); +} + /// A patch for a tool call that was never opened is logged, not fatal. #[test] fn reports_a_patch_before_open() { diff --git a/crates/agent_fold/src/domain/test/snapshots/agent_fold__domain__test__fold__folds_every_official_tool_kind.snap b/crates/agent_fold/src/domain/test/snapshots/agent_fold__domain__test__fold__folds_every_official_tool_kind.snap new file mode 100644 index 00000000000..688d325e053 --- /dev/null +++ b/crates/agent_fold/src/domain/test/snapshots/agent_fold__domain__test__fold__folds_every_official_tool_kind.snap @@ -0,0 +1,103 @@ +--- +source: crates/agent_fold/src/domain/test/fold.rs +assertion_line: 200 +expression: agent.parts +--- +NonEmpty { + inner: [ + ToolUse( + ToolUse { + id: ToolUseId( + "del", + ), + label: "del", + status: Completed, + detail: Delete { + paths: [ + "/repo/dead.rs", + ], + }, + }, + ), + ToolUse( + ToolUse { + id: ToolUseId( + "mv", + ), + label: "mv", + status: Completed, + detail: Move { + paths: [ + "/repo/from.rs", + "/repo/to.rs", + ], + }, + }, + ), + ToolUse( + ToolUse { + id: ToolUseId( + "search", + ), + label: "search", + status: Completed, + detail: Search { + paths: [ + "/repo/a.rs", + ], + output: Some( + "1 match", + ), + }, + }, + ), + ToolUse( + ToolUse { + id: ToolUseId( + "fetch", + ), + label: "fetch", + status: Completed, + detail: Fetch { + output: Some( + "page body", + ), + }, + }, + ), + ToolUse( + ToolUse { + id: ToolUseId( + "think", + ), + label: "think", + status: Completed, + detail: Think { + output: Some( + "reasoning aloud", + ), + }, + }, + ), + ToolUse( + ToolUse { + id: ToolUseId( + "mystery", + ), + label: "mystery", + status: Completed, + detail: Other { + kind: "other", + output: Some( + "how should this work?", + ), + input: Some( + Object { + "foo": String("bar"), + }, + ), + }, + }, + ), + ], +} diff --git a/crates/agent_fold/src/domain/test/snapshots/agent_fold__domain__test__real_recordings__resumed_no_prompt.snap b/crates/agent_fold/src/domain/test/snapshots/agent_fold__domain__test__real_recordings__resumed_no_prompt.snap index dbd693d10e3..e18ebc94bed 100644 --- a/crates/agent_fold/src/domain/test/snapshots/agent_fold__domain__test__real_recordings__resumed_no_prompt.snap +++ b/crates/agent_fold/src/domain/test/snapshots/agent_fold__domain__test__real_recordings__resumed_no_prompt.snap @@ -158,15 +158,9 @@ expression: messages ), label: "Agent", status: Completed, - detail: Other { - kind: "think", - input: Some( - Object { - "subagent_type": String("Explore"), - "description": String("Find comms message models"), - "run_in_background": Bool(false), - "prompt": String("In /Users/eric/Code/macro, find the canonical Rust models for **channel messages** (comms messages) — the type that gets rendered in a channel in the frontend, plus its content/body representation.\n\nBe very thorough. I need to know:\n1. Where the channel message struct(s) live (crate + file paths + line numbers) — both the DB row type and the API/response type if they differ.\n2. How message *content* is represented — plain text? rich content blocks? an enum of block types? Is there any existing notion of structured/rich content parts?\n3. Whether there's any existing notion of a non-human / bot / AI author on a channel message (author type enum, bot user, sender kind, etc.).\n4. Any existing message \"kind\"/\"type\" discriminator on channel messages (e.g. system messages, join/leave, call events) — how are non-plain-text messages modeled today?\n5. Where the TypeScript types for these come from (specta export? generated swagger types?) and the frontend component that renders a channel message.\n6. Also check for the `chat` crate's AI message model (assistant messages, tool calls, content blocks) since AI chat messages already exist somewhere in this repo — I want to compare how AI content is modeled there vs channel messages.\n\nReport concrete file:line references and paste the key struct/enum definitions verbatim. Do not modify any files."), - }, + detail: Think { + output: Some( + "Here is the complete map.\n\n## 1. Channel message structs (Rust)\n\n**Domain (canonical) — `crates/channels/src/domain/models.rs`**\n\n- `ChannelMessage` (top-level timeline message) — **line 140**\n```rust\n/// A top-level message with thread info, reactions, and attachments.\n#[derive(Debug, Clone)]\npub struct ChannelMessage {\n pub id: Uuid,\n pub channel_id: Uuid,\n pub sender_id: String,\n /// For an agent (bot) message, the id of the user who triggered it.\n pub triggered_by: Option,\n /// Bot profile when the sender is a bot.\n pub bot_profile: Option,\n pub content: String,\n pub created_at: DateTime,\n pub updated_at: DateTime,\n pub edited_at: Option>,\n pub deleted_at: Option>,\n pub thread: ThreadInfo,\n pub reactions: Vec,\n pub attachments: Vec,\n}\n```\n- `ThreadInfo` (line 200), `ThreadReply` (line 212 — same shape, no `thread`/`deleted_at`), `CountedReaction` (line 240), `MessageAttachment` (line 249)\n- `ChannelContextMessage` (line 366) — flat shape used by the message-context endpoint (has `thread_id`)\n- `RecentChannelMessage` (line 175) — lightweight shape used for AI attachments / search indexing, adds `mentions: Vec` formatted `{ENTITY_TYPE}:{ENTITY_ID}`\n- `MutatedMessage` (line 1028) — what mutations return / what gets broadcast over realtime; `sender_id: ChannelSender<'static>`\n- Raw DB row types: `TopLevelMessageRow` (line 431), `ThreadReplyRow` (line 465), `ThreadData` (line 452)\n- Requests: `PostMessageRequest` (line 913 — `content: String`, `mentions: Vec`, `thread_id`, `attachments`, `nonce`, internal-only `triggered_by`), `PatchMessageRequest` (line 946), `PostMessageResponse` (line 939)\n- `SimpleMention` (line 797) = `{ entity_type: String, entity_id: String }`\n\n**DB row / SQL** — `crates/channels/src/outbound/pg_channels_repo.rs` (row structs at lines 72–290: `TopLevelRow`, `ThreadDataRow`, `ContextMessageRow` at 165, `MutatedMessageRow` at 209).\nTable DDL: `crates/macro_db_client/migrations/20251104101012_comms_db_schema.sql:93`\n```sql\nCREATE TABLE public.comms_messages\n(\n id uuid NOT NULL, channel_id uuid NOT NULL, thread_id uuid,\n sender_id text NOT NULL, content text NOT NULL,\n created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,\n edited_at timestamp, deleted_at timestamp\n);\n```\nplus `crates/macro_db_client/migrations/20260629171949_add_comms_messages_triggered_by.sql:11` → `ADD COLUMN IF NOT EXISTS triggered_by_user_id text;`\nContent is a single `text` column. There is **no** content-type/kind column, no jsonb.\n\n**API/response types — `crates/channels/src/inbound/axum_router.rs`** (mounted by `services/document_storage_service/src/main.rs:1072`)\n- `ApiChannelMessage` — **line 1911**, `ApiChannelMessagesPage` — 1817, `ApiThreadInfo` — 2107, `ApiThreadReply` — 2137, `ApiChannelContextMessage` — 1978, `ApiMessageSender` — 1829, `ApiMessageSenderType` — 1849\n```rust\npub struct ApiChannelMessage {\n id: Uuid, channel_id: Uuid,\n sender_id: String,\n sender: ApiMessageSender,\n content: String,\n created_at/updated_at/edited_at/deleted_at,\n thread: ApiThreadInfo,\n reactions: Vec,\n attachments: Vec,\n}\n```\n\n**Other parallel copies of the same shape:**\n- Soup/inbox: `crates/models_soup/src/comms.rs:78` `ChannelMessage`, `:120` `SoupChannelThread`, `:155` `SoupMessageSender`, `:167` `SoupMessageSenderType`\n- MCP toolset: `crates/channels/src/inbound/toolset/types.rs` (`ToolMessageKind` at 33, message types ~263)\n- Search indexing: `crates/comms_db_client/src/messages/get_channel_message.rs:6` `ChannelMessageInfo`; `crates/models_search/src/channel.rs:129`; `crates/opensearch_client/src/search/channels.rs:23`\n- Realtime websocket payload: `crates/channels/src/outbound/connection_gateway_realtime.rs:159` `MessageRealtimeData` (flattens `MutatedMessage` + `MessageRealtimeSender` at 166)\n\n## 2. How content is represented\n\n**Plain `String`.** Markdown text, rendered client-side. No content blocks, no enum of part types, no structured/rich parts anywhere in the channel path — every layer (`ChannelMessage`, `ThreadReply`, `ApiChannelMessage`, `MutatedMessage`, `TopLevelMessageRow`, soup, search, DB column) carries `content: String`.\n\nThe only structure adjacent to content:\n- Inline mention markup embedded *inside* the markdown string, extracted by the lexical service into `SimpleMention` rows (`crates/lexical_mention_extractor/src/lib.rs:27`, table `comms_entity_mentions`).\n- `MessageAttachment` / `comms_attachments` as a sibling list (entity_type/entity_id + width/height), not part of content.\n\nFrontend rendering: `apps/web/src/features/channel/Message/Content.tsx:14` — takes `message().content` and passes it whole to `StaticMarkdown` with `channelTheme`. `isEmojiOnly` bumps to big-emoji styling. That's the entire content rendering model.\n\n## 3. Non-human / bot / AI author — yes, this exists\n\n- `crates/channel_sender/src/lib.rs:18` — `ChannelSender<'a>(Either)`; `sender_id` in the DB is a namespaced principal string, parsed via `parse_from_str` (tries `BotIdStr` first, i.e. `bot|`, else macro user id).\n- `crates/channels/src/domain/models.rs:25` — `BotSenderProfile { name: String, avatar_url: Option }`\n- API discriminator: `crates/channels/src/inbound/axum_router.rs:1829-1853`\n```rust\npub struct ApiMessageSender {\n #[serde(rename = \"type\")] sender_type: ApiMessageSenderType,\n id: String, // bot uuid or user id, namespace stripped\n name: Option, // bot display name\n avatar_url: Option, // bot avatar\n triggered_by: Option, // user who triggered the agent message\n}\npub enum ApiMessageSenderType { User, Bot } // snake_case on the wire\n```\nbuilt by `ApiMessageSender::from_message_sender` (line 1893) — bot profile + `triggered_by` only attached when the sender is a bot.\n- `triggered_by` is set internally, never from the wire (`PostMessageRequest.triggered_by`, `#[serde(skip)]`).\n- Bot model: `crates/bots/src/domain/models.rs:104` `Bot { id: BotId, kind: BotKind, owner: Option, name, handle, description, avatar_url, ... }`; `BotKind { Owned, System }` at line 14; `BotOwner { User{user_id}, Team{team_id} }` at line 87. Related crates: `crates/bot_id`, `crates/channel_bots`.\n- Frontend: `apps/web/src/lib/queries/channel/message-sender.ts` (`isBotSenderId`, `senderFromStorageId`, `normalizeChannelMessageSender`), `apps/web/src/features/channel/Message/AgentBadge.tsx` (renders an \"Agent\" pill when `isBotSenderId(sender_id)`), `apps/web/src/features/channel/macroAi.ts` + `@core/constant/macroAgent` (the first-party Macro agent bot identity, `MACRO_AGENT_PRINCIPAL_ID` = `bot|`).\n\n## 4. Message \"kind\"/\"type\" discriminator — none for content\n\nThe only `kind` that exists is **position**, not content type: `ChannelMessageKind { TopLevelMessage, ThreadReply }` at `crates/channels/src/domain/models.rs:104`, surfaced as `ApiChannelMessageKind` (`axum_router.rs:2198`) and `ToolMessageKind` (`toolset/types.rs:33`).\n\nThere is **no** system-message / join-leave / call-event variant on `comms_messages`. Non-plain-text UI is modeled *outside* the message stream:\n- Active call banner: `apps/web/src/features/channel/Channel/ActiveCallMessage.tsx` — a separate component driven by `useActiveCallQuery`, not a message row.\n- Channel-created marker: `apps/web/src/features/channel/Message/ChannelCreatedIndicator.tsx` — list decoration.\n- Date/new dividers: `DateDivider.tsx`, `NewDivider.tsx` — list decorations keyed off `ChannelMessageListMeta` (`Message/list-meta.ts`).\n- Deleted state: branch on `message.deleted_at != null` (`ChannelMessage.tsx:227` → `DeletedMessageLayout`).\n- Call transcripts fake a message: `apps/web/src/features/block-call/component/CallTranscript.tsx:79` `segmentToApiChannelMessage(...)` synthesizes an `ApiChannelMessage` from a transcript segment. Same trick for doc comments: `apps/web/src/lib/core/comments/discussion/Discussion.tsx:182` `discussionCommentToApiChannelMessage`.\n\nSo today the only way to render anything non-plain-text is to either synthesize a fake `ApiChannelMessage` or add a sibling component to the list.\n\n## 5. TypeScript types + renderer\n\n**Not specta.** utoipa → OpenAPI JSON → **orval** codegen.\n- Config: `apps/web/src/lib/service-clients/orval.config.ts` (channels live in the `document_storage_service` spec → `service-storage`).\n- Generated: `apps/web/src/lib/service-clients/service-storage/generated/schemas/apiChannelMessage.ts`, `apiMessageSender.ts`, `apiMessageSenderType.ts` (`{ user: 'user', bot: 'bot' }`), `apiThreadInfo.ts`, `apiThreadReply.ts`, `apiCountedReaction.ts`, `apiMessageAttachment.ts`, `apiChannelMessagesPage.ts`, `apiChannelMessageKind.ts`, plus `soupMessageSender*.ts`. Imported as `@service-storage/generated/schemas/...` or `@service-storage/client` (tsconfig path at `apps/web/tsconfig.json:92`).\n\n**Renderer:**\n- `apps/web/src/features/channel/Message/ChannelMessage.tsx:198` — `ChannelMessage()`, the row component (Switch over deleted / grouped / regular layouts).\n- Compound namespace: `apps/web/src/features/channel/Message/Message.ts` — `Message.{Root, Layout, Slot, SenderName, AgentBadge, FromPill, SenderIcon, Timestamp, Content, Attachments, Reactions, EditedIndicator, HoverActions, ActionMenu, MediaPreview, ...}`\n- Frontend view model (deliberately looser than the API type): `apps/web/src/features/channel/Message/types.ts` — `MessageData { id, content, sender_id, sender?: ApiMessageSender, created_at, updated_at, deleted_at?, edited_at?, thread_id?, attachments, reactions }`. This is what the whole Message tree consumes via `useMessage()` (`Message/context.ts`).\n- Consumers: `apps/web/src/features/channel/Thread/ChannelThread.tsx:308`, `Thread/ThreadReplyList.tsx:85`, `Channel/ThreadList.tsx` (virtua virtualizer), `apps/web/src/features/entity/composed/list-entity/channel.tsx:171`.\n- Queries/cache: `apps/web/src/lib/queries/channel/channel-messages.ts`, `reconcile.ts`, `message-sender.ts`.\n\n## 6. AI chat message model (for comparison)\n\n**Content parts enum — `crates/agent/src/types.rs`**\n- `Role` — line 32: `{ User, Assistant, System }` (lowercase)\n- `AssistantMessagePart` — **line 52**, `#[serde(tag = \"type\", rename_all = \"camelCase\")]`:\n```rust\npub enum AssistantMessagePart {\n Text { text: String },\n ToolCall { name: String, json: serde_json::Value, id: String },\n McpToolCall { name: String, service: String, display_name: Option, json: serde_json::Value, id: String },\n ToolCallResponseJson { name: String, json: serde_json::Value, id: String },\n ToolCallErr { name: String, description: String, id: String },\n Thinking { thinking: String },\n}\n```\n- `ChatMessageContent` — **line 136**:\n```rust\n#[serde(untagged)]\npub enum ChatMessageContent {\n Text(String),\n AssistantMessageParts(Vec),\n}\n```\nwith helpers `user_message_text`, `assistant_message_text`, `assistant_message_text_with_tools`, `message_text`, `message_text_with_tools` (lines 148–232). `ChatMessage { content, role, attachments }` at line 240; `ChatMessages(Vec)` at 255.\n\n**Persisted / API types — `crates/model/src/chat/message.rs`**\n- `ChatMessage` (line 10): `{ id: String, content: ChatMessageContent, role: Role, model: Option }`, `#[serde(rename_all = \"camelCase\")]`, `sqlx::FromRow`\n- `ChatMessageWithAttachments` (line 23): same + `attachments: Vec>`\n- `NewChatMessage` (line 35)\n- DB: `public.\"ChatMessage\"` with **`content jsonb NOT NULL`** and `role text` — see `services/delete_chat_handler/migrations/20240325143515_basic_schema_for_testing.sql:343`\n- Chat crate: `crates/chat/src/domain/models/chat.rs:83` `ChatResponse { messages: Vec, ... }`; `crates/chat/src/domain/models/message.rs:8` `PatchChatMessageArgs { message_id, content: ChatMessageContent }`, `WebCitation` (line 17), `ResolvedMessageContent` (line 34); `crates/chat/src/domain/events.rs:104` `ChatMessageRole`.\n\n**Frontend AI renderer** (the analogue of `Message/Content.tsx`): `apps/web/src/lib/core/component/AI/component/message/AssistantMessageParts.tsx` — switches on `part.type` (`toolCall` / `mcpToolCall` / `toolCallErr` / `thinking` / `text`) into `RenderTool`, `McpToolCall`, `ThinkingBlock`, `ChatMessageMarkdown`, grouping tool activity into `AssistantActivityGroup`. Types come from `@service-cognition/generated/schemas/assistantMessagePart` (orval, cognition service spec).\n\n**Key contrast:** AI chat already has a tagged, extensible part enum persisted as jsonb with a per-part-type frontend renderer registry; channel messages are a flat `text` column with a single markdown renderer, and the only author/kind discriminators are `ApiMessageSenderType { user, bot }` (author) and `ChannelMessageKind { TopLevelMessage, ThreadReply }` (position). There is no content-kind discriminator on channel messages at all.", ), }, }, @@ -362,6 +356,9 @@ expression: messages status: Failed, detail: Other { kind: "other", + output: Some( + "```\nThe user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.\n\nNote: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions.\n```", + ), input: Some( Object { "questions": Array [ @@ -1772,6 +1769,9 @@ expression: messages status: Completed, detail: Other { kind: "other", + output: Some( + "Launching skill: cloud-storage-hexagonal-architecture", + ), input: Some( Object { "skill": String("cloud-storage-hexagonal-architecture"), diff --git a/crates/agent_fold/src/inbound/mod.rs b/crates/agent_fold/src/inbound/mod.rs index 91c70d70740..6f3b71b8a0e 100644 --- a/crates/agent_fold/src/inbound/mod.rs +++ b/crates/agent_fold/src/inbound/mod.rs @@ -1,9 +1,14 @@ //! Adapters that drive the fold from outside. //! -//! There is one: [`wasm`], the browser's. Services do not need an adapter - -//! they call [`crate::domain::ports::FoldedMessageRepo`] directly - and the -//! HTTP surface that used to fold on their behalf is gone, because the client -//! folds for itself now. +//! There are two: [`wire`], the shape a browser sees, and [`wasm`], the +//! wasm-bindgen glue that actually carries it there. Services do not need an +//! adapter - they call [`crate::domain::ports::FoldedMessageRepo`] directly - +//! and the HTTP surface that used to fold on their behalf is gone, because +//! the client folds for itself now. + +/// The wire shape a browser sees, kept apart from the wasm-bindgen glue so a +/// native binary can derive TypeScript from it. See the module docs. +pub mod wire; /// The wasm-bindgen entry point the web client folds through. #[cfg(target_arch = "wasm32")] diff --git a/crates/agent_fold/src/inbound/wasm.rs b/crates/agent_fold/src/inbound/wasm.rs index 82c2f1b2ae4..d1999cab92b 100644 --- a/crates/agent_fold/src/inbound/wasm.rs +++ b/crates/agent_fold/src/inbound/wasm.rs @@ -9,14 +9,10 @@ //! content}` per frame - so a caller passes bytes through rather than //! translating them, and catching up and following are the same code. //! -//! # Why the types are written out again -//! -//! [`crate::domain::model`] is not serializable, on purpose: it is the fold's -//! own vocabulary and owes nothing to any transport. So this module declares -//! the shape JavaScript sees, the same way the HTTP handler used to before the -//! fold moved to the client. The pairing to keep honest is with -//! `apps/web/src/lib/core/agent-fold/types.ts`, which is hand-written against -//! these - enums tagged with `kind`, fields camelCase. +//! The shape JavaScript actually sees lives in [`crate::inbound::wire`], not +//! here - this module is only the wasm-bindgen glue that carries values of +//! those types across the boundary. See that module's docs for why they are +//! kept apart. //! //! # Why catching up is not a loop of pushes //! @@ -29,14 +25,11 @@ use crate::domain::fold::{FoldMachineImpl, fold}; use crate::domain::log::{AgentSessionId, AgentSessionLog, Message}; -use crate::domain::model::{ - Author, FileDiff, FoldedMessage, IncrementalFoldResult, MessagePart, Permission, - PermissionOption, PermissionOptionKind, PermissionOutcome, StopReason, ToolDetail, ToolStatus, - ToolUse, -}; +use crate::domain::model::FoldedMessage as ModelFoldedMessage; use crate::domain::ports::FoldMachine; +use crate::inbound::wire::{FoldedMessage, FoldedMessageChange}; use macro_user_id::user_id::MacroUserIdStr; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use wasm_bindgen::prelude::*; /// Fold one session's log into the messages a channel renders. @@ -123,7 +116,7 @@ impl FoldStream { .map_err(|error| JsValue::from_str(&format!("log entry is not readable: {error}")))?; let result = self.machine.push(entry.into_log(self.session)); - let Some(change) = JsFoldedMessageChange::new(self.session, result) else { + let Some(change) = FoldedMessageChange::new(self.session, result) else { return Ok(JsValue::NULL); }; @@ -142,12 +135,12 @@ impl FoldStream { /// /// Returns a JS string describing what could not be encoded. pub fn messages(&self) -> Result { - let messages: Vec = self + let messages: Vec = self .machine .messages() .iter() .cloned() - .map(|message| JsFoldedMessage::new(self.session, message)) + .map(|message| FoldedMessage::new(self.session, message)) .collect(); serde_wasm_bindgen::to_value(&messages).map_err(|error| { @@ -178,11 +171,11 @@ fn parse_log(session: AgentSessionId, entries: JsValue) -> Result, + messages: Vec, ) -> Result { - let messages: Vec = messages + let messages: Vec = messages .into_iter() - .map(|message| JsFoldedMessage::new(session, message)) + .map(|message| FoldedMessage::new(session, message)) .collect(); serde_wasm_bindgen::to_value(&messages) @@ -215,372 +208,3 @@ impl LogEntry { } } } - -/// One renderable message, mirroring [`FoldedMessage`]. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct JsFoldedMessage { - /// Session that scopes this message id. - agent_session_id: String, - /// The turn within the session, assigned in log order from zero. - turn: u32, - author: JsAuthor, - /// Ordered renderable content. Never empty. - parts: Vec, - /// How the turn ended, on the agent message that closed it. Absent while - /// the turn is in flight, or when the session died without a response. - #[serde(skip_serializing_if = "Option::is_none")] - stop: Option, -} - -impl JsFoldedMessage { - fn new(session: AgentSessionId, message: FoldedMessage) -> Self { - Self { - agent_session_id: session.to_string(), - turn: message.id.0, - author: message.author.into(), - parts: message - .parts - .into_inner() - .into_iter() - .map(Into::into) - .collect(), - stop: message.stop.map(Into::into), - } - } -} - -/// What one pushed frame changed, mirroring [`IncrementalFoldResult`]. -/// -/// The message is carried whole rather than as a delta, so a reader applies -/// either kind the same way - replace whatever it holds under this -/// `agentSessionMessageId`. `kind` is what tells it whether a row for that id -/// exists yet: a session streaming into a channel has no placeholder message -/// for a turn until the fold first derives it, and `new` is the one moment -/// the client can create one. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct JsFoldedMessageChange { - /// `"new"` the first time a message is reported, `"update"` after. - kind: &'static str, - message: JsFoldedMessage, -} - -impl JsFoldedMessageChange { - /// `None` for [`IncrementalFoldResult::Unchanged`] - nothing to report. - fn new(session: AgentSessionId, result: IncrementalFoldResult<'_>) -> Option { - let (kind, message) = match result { - IncrementalFoldResult::NewMessage(message) => ("new", message), - IncrementalFoldResult::MessageUpdate(message) => ("update", message), - IncrementalFoldResult::Unchanged => return None, - }; - Some(Self { - kind, - message: JsFoldedMessage::new(session, message.into_owned()), - }) - } -} - -/// Who produced a message, mirroring [`Author`]. -#[derive(Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum JsAuthor { - /// A person, via `session/prompt`. - User { - /// Absent when the prompt was not attributed to anyone. - #[serde(rename = "userId", skip_serializing_if = "Option::is_none")] - user_id: Option, - }, - /// The agent. - Agent, -} - -impl From for JsAuthor { - fn from(author: Author) -> Self { - match author { - Author::User(user_id) => Self::User { - user_id: user_id.map(|id| id.to_string()), - }, - Author::Agent => Self::Agent, - } - } -} - -/// A unit of renderable content, mirroring [`MessagePart`]. -#[derive(Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum JsMessagePart { - /// Prose from the user or the agent. - Text { - /// The prose. - text: String, - }, - /// The agent's reasoning, which a reader may want to hide by default. - Thought { - /// The reasoning. - text: String, - }, - /// A tool the agent invoked. - ToolUse { - /// The ACP `toolCallId`. - id: String, - /// What to show as the tool's name. - label: String, - status: JsToolStatus, - detail: JsToolDetail, - }, - /// The agent asking to proceed with a tool call. - Permission { - /// The `toolCallId` permission was requested for. - #[serde(rename = "toolCall")] - tool_call: String, - /// The choices offered, in the order ACP listed them. - options: Vec, - /// What the user chose. Absent while the request is outstanding, or - /// when it resolved into something the wire does not model - see - /// [`permission_outcome`]. - #[serde(skip_serializing_if = "Option::is_none")] - outcome: Option, - }, -} - -impl From for JsMessagePart { - fn from(part: MessagePart) -> Self { - match part { - MessagePart::Text(text) => Self::Text { text }, - MessagePart::Thought(text) => Self::Thought { text }, - MessagePart::ToolUse(ToolUse { - id, - label, - status, - detail, - }) => Self::ToolUse { - id: id.0, - label, - status: status.into(), - detail: detail.into(), - }, - MessagePart::Permission(Permission { - tool_call, - options, - outcome, - }) => Self::Permission { - tool_call: tool_call.0, - options: options.into_iter().map(Into::into).collect(), - outcome: permission_outcome(outcome), - }, - } - } -} - -/// How far a tool call progressed, mirroring [`ToolStatus`]. -#[derive(Serialize)] -#[serde(rename_all = "snake_case")] -enum JsToolStatus { - Pending, - Running, - Completed, - Failed, -} - -impl From for JsToolStatus { - fn from(status: ToolStatus) -> Self { - match status { - ToolStatus::Pending => Self::Pending, - ToolStatus::Running => Self::Running, - ToolStatus::Completed => Self::Completed, - ToolStatus::Failed => Self::Failed, - } - } -} - -/// What a tool call actually did, mirroring [`ToolDetail`]. -#[derive(Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum JsToolDetail { - /// A shell command. - Terminal { - /// The command line, when the harness reported one. - #[serde(skip_serializing_if = "Option::is_none")] - command: Option, - /// Captured output, ANSI escape sequences left in place. - #[serde(skip_serializing_if = "Option::is_none")] - output: Option, - /// Process exit code, when the harness reported one. - #[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")] - exit_code: Option, - }, - /// One or more file modifications. - Edit { - /// The diffs ACP reported for this call. - diffs: Vec, - }, - /// A file read. - Read { - /// Paths this call touched. - paths: Vec, - }, - /// Anything else, including tools the fold has no special rendering for. - Other { - /// ACP's tool kind, as its wire string. - #[serde(rename = "acpKind")] - acp_kind: String, - /// The tool's input, when reported. - #[serde(skip_serializing_if = "Option::is_none")] - input: Option, - }, -} - -impl From for JsToolDetail { - fn from(detail: ToolDetail) -> Self { - match detail { - ToolDetail::Terminal { - command, - output, - exit_code, - } => Self::Terminal { - command, - // The escapes survive the fold; turning them into something a - // reader can see is the reader's business, not this crate's. - output: output.map(|output| output.0), - exit_code, - }, - ToolDetail::Edit { diffs } => Self::Edit { - diffs: diffs.into_iter().map(Into::into).collect(), - }, - ToolDetail::Read { paths } => Self::Read { - paths: paths - .into_iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(), - }, - ToolDetail::Other { kind, input } => Self::Other { - acp_kind: kind, - input, - }, - } - } -} - -/// A file modification a tool reported, mirroring [`FileDiff`]. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct JsFileDiff { - /// The file that changed. - path: String, - /// Prior contents, absent when the file is new. - #[serde(skip_serializing_if = "Option::is_none")] - old_text: Option, - /// New contents. - new_text: String, -} - -impl From for JsFileDiff { - fn from(diff: FileDiff) -> Self { - Self { - path: diff.path.to_string_lossy().into_owned(), - old_text: diff.old_text, - new_text: diff.new_text, - } - } -} - -/// One choice offered for a permission request, mirroring -/// [`PermissionOption`]. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct JsPermissionOption { - /// The id to report back when this option is chosen. - id: String, - /// Label to show. - name: String, - /// ACP's option kind, as its wire string. - kind: String, -} - -impl From for JsPermissionOption { - fn from(option: PermissionOption) -> Self { - Self { - id: option.id, - name: option.name, - kind: permission_option_kind_wire(option.kind).to_owned(), - } - } -} - -/// [`PermissionOption::kind`]'s wire string, the vocabulary -/// `apps/web/src/lib/core/agent-fold/types.ts`'s `PermissionOption.kind` is -/// hand-written against. -fn permission_option_kind_wire(kind: PermissionOptionKind) -> &'static str { - match kind { - PermissionOptionKind::AllowOnce => "allow_once", - PermissionOptionKind::AllowAlways => "allow_always", - PermissionOptionKind::RejectOnce => "reject_once", - PermissionOptionKind::RejectAlways => "reject_always", - } -} - -/// How a permission request resolved, mirroring [`PermissionOutcome`]. -#[derive(Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum JsPermissionOutcome { - /// An option was chosen. - Selected { - /// The chosen option's id. - #[serde(rename = "optionId")] - option_id: String, - }, - /// The request was cancelled without a choice. - Cancelled, -} - -/// [`PermissionOutcome`] as the wire sees it: whether an option was chosen, -/// collapsing every way it was not into absence. -/// -/// The wire does not yet distinguish *why* nothing was chosen - -/// [`PermissionOutcome::Pending`], [`PermissionOutcome::Errored`] and -/// [`PermissionOutcome::Unrecognized`] all read as "no outcome" to a reader -/// today. That is a real loss (an errored or unrecognized request will not -/// resolve further, unlike a pending one), but widening the wire to say so is -/// a frontend change of its own, not implied by giving the fold's own state -/// machine a name for each case. -fn permission_outcome(outcome: PermissionOutcome) -> Option { - match outcome { - PermissionOutcome::Selected { option_id } => { - Some(JsPermissionOutcome::Selected { option_id }) - } - PermissionOutcome::Cancelled => Some(JsPermissionOutcome::Cancelled), - PermissionOutcome::Pending - | PermissionOutcome::Errored - | PermissionOutcome::Unrecognized => None, - } -} - -/// Why a turn stopped, mirroring [`StopReason`]. -#[derive(Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum JsStopReason { - EndTurn, - MaxTokens, - MaxTurnRequests, - Refusal, - Cancelled, - /// A stop reason this fold does not model, as its wire string. - Other { - /// The wire string. - reason: String, - }, -} - -impl From for JsStopReason { - fn from(stop: StopReason) -> Self { - match stop { - StopReason::EndTurn => Self::EndTurn, - StopReason::MaxTokens => Self::MaxTokens, - StopReason::MaxTurnRequests => Self::MaxTurnRequests, - StopReason::Refusal => Self::Refusal, - StopReason::Cancelled => Self::Cancelled, - StopReason::Other(reason) => Self::Other { reason }, - } - } -} diff --git a/crates/agent_fold/src/inbound/wire.rs b/crates/agent_fold/src/inbound/wire.rs new file mode 100644 index 00000000000..01e623e6d06 --- /dev/null +++ b/crates/agent_fold/src/inbound/wire.rs @@ -0,0 +1,452 @@ +//! The wire shape a browser sees, independent of how it gets there. +//! +//! [`crate::domain::model`] is not serializable, on purpose: it is the fold's +//! own vocabulary and owes nothing to any transport. These types declare the +//! shape JavaScript sees instead - enums tagged with `kind`, fields camelCase +//! - the same way the HTTP handler used to before the fold moved to the +//! client. +//! +//! Kept apart from [`crate::inbound::wasm`], which is wasm32-only, so a +//! native binary can derive `apps/web/src/lib/service-clients/service-agent-fold/generated/types.ts` from +//! these with specta (`cargo run -p agent_fold --bin export_types`) instead +//! of a human keeping that file in sync by hand. Every domain counterpart +//! this module mirrors shares its exact name, so the import list below +//! aliases each one that would otherwise collide with the wire type declared +//! against it - the wire type keeps the bare name, since that is the one +//! specta exports and the one a consumer of this module actually wants. + +use crate::domain::log::AgentSessionId; +use crate::domain::model::{ + Author, FileDiff as ModelFileDiff, FoldedMessage as ModelFoldedMessage, IncrementalFoldResult, + MessagePart, Permission, PermissionOption as ModelPermissionOption, PermissionOptionKind, + PermissionOutcome as ModelPermissionOutcome, StopReason as ModelStopReason, + ToolDetail as ModelToolDetail, ToolStatus as ModelToolStatus, ToolUse, +}; +use serde::Serialize; +use specta::Type; +use std::path::PathBuf; + +/// One renderable message, mirroring [`crate::domain::model::FoldedMessage`]. +#[derive(Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FoldedMessage { + /// Session that scopes this message id. + agent_session_id: String, + /// The turn within the session, assigned in log order from zero. + turn: u32, + author: FoldedAuthor, + /// Ordered renderable content. Never empty. + parts: Vec, + /// How the turn ended, on the agent message that closed it. Absent while + /// the turn is in flight, or when the session died without a response. + stop: Option, +} + +impl FoldedMessage { + /// Build the wire form of `message`, keyed to `session`. + #[must_use] + pub fn new(session: AgentSessionId, message: ModelFoldedMessage) -> Self { + Self { + agent_session_id: session.to_string(), + turn: message.id.0, + author: message.author.into(), + parts: message + .parts + .into_inner() + .into_iter() + .map(Into::into) + .collect(), + stop: message.stop.map(Into::into), + } + } +} + +/// What one pushed frame changed, mirroring [`IncrementalFoldResult`]. +/// +/// The message is carried whole rather than as a delta, so a reader applies +/// either kind the same way - replace whatever it holds under this session, +/// turn, and author. `kind` is what tells it whether a row for that id +/// exists yet: a session streaming into a channel has no placeholder message +/// for a turn until the fold first derives it, and `new` is the one moment +/// the client can create one. +#[derive(Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FoldedMessageChange { + /// `"new"` the first time a message is reported, `"update"` after. + kind: &'static str, + message: FoldedMessage, +} + +impl FoldedMessageChange { + /// `None` for [`IncrementalFoldResult::Unchanged`] - nothing to report. + #[must_use] + pub fn new(session: AgentSessionId, result: IncrementalFoldResult<'_>) -> Option { + let (kind, message) = match result { + IncrementalFoldResult::NewMessage(message) => ("new", message), + IncrementalFoldResult::MessageUpdate(message) => ("update", message), + IncrementalFoldResult::Unchanged => return None, + }; + Some(Self { + kind, + message: FoldedMessage::new(session, message.into_owned()), + }) + } +} + +/// Who produced a message, mirroring [`Author`]. +#[derive(Serialize, Type)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum FoldedAuthor { + /// A person, via `session/prompt`. + User { + /// Absent when the prompt was not attributed to anyone. + #[serde(rename = "userId")] + user_id: Option, + }, + /// The agent. + Agent, +} + +impl From for FoldedAuthor { + fn from(author: Author) -> Self { + match author { + Author::User(user_id) => Self::User { + user_id: user_id.map(|id| id.to_string()), + }, + Author::Agent => Self::Agent, + } + } +} + +/// A unit of renderable content, mirroring [`MessagePart`]. +#[derive(Serialize, Type)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum FoldedMessagePart { + /// Prose from the user or the agent. + Text { + /// The prose. + text: String, + }, + /// The agent's reasoning, which a reader may want to hide by default. + Thought { + /// The reasoning. + text: String, + }, + /// A tool the agent invoked. + ToolUse { + /// The ACP `toolCallId`. + id: String, + /// What to show as the tool's name. + label: String, + status: ToolStatus, + detail: ToolDetail, + }, + /// The agent asking to proceed with a tool call. + Permission { + /// The `toolCallId` permission was requested for. + #[serde(rename = "toolCall")] + tool_call: String, + /// The choices offered, in the order ACP listed them. + options: Vec, + /// What the user chose. Absent while the request is outstanding, or + /// when it resolved into something the wire does not model - see + /// [`permission_outcome`]. + outcome: Option, + }, +} + +impl From for FoldedMessagePart { + fn from(part: MessagePart) -> Self { + match part { + MessagePart::Text(text) => Self::Text { text }, + MessagePart::Thought(text) => Self::Thought { text }, + MessagePart::ToolUse(ToolUse { + id, + label, + status, + detail, + }) => Self::ToolUse { + id: id.0, + label, + status: status.into(), + detail: detail.into(), + }, + MessagePart::Permission(Permission { + tool_call, + options, + outcome, + }) => Self::Permission { + tool_call: tool_call.0, + options: options.into_iter().map(Into::into).collect(), + outcome: permission_outcome(outcome), + }, + } + } +} + +/// How far a tool call progressed, mirroring [`crate::domain::model::ToolStatus`]. +#[derive(Serialize, Type)] +#[serde(rename_all = "snake_case")] +enum ToolStatus { + Pending, + Running, + Completed, + Failed, +} + +impl From for ToolStatus { + fn from(status: ModelToolStatus) -> Self { + match status { + ModelToolStatus::Pending => Self::Pending, + ModelToolStatus::Running => Self::Running, + ModelToolStatus::Completed => Self::Completed, + ModelToolStatus::Failed => Self::Failed, + } + } +} + +/// What a tool call actually did, mirroring [`crate::domain::model::ToolDetail`]. +#[derive(Serialize, Type)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ToolDetail { + /// A shell command. + Terminal { + /// The command line, when the harness reported one. + command: Option, + /// Captured output, ANSI escape sequences left in place. + output: Option, + /// Process exit code, when the harness reported one. + #[serde(rename = "exitCode")] + exit_code: Option, + }, + /// One or more file modifications. + Edit { + /// The diffs ACP reported for this call. + diffs: Vec, + }, + /// A file read. + Read { + /// Paths this call touched. + paths: Vec, + }, + /// One or more files removed. + Delete { + /// Paths this call touched. + paths: Vec, + }, + /// One or more files moved or renamed. + Move { + /// Paths this call touched. + paths: Vec, + }, + /// A search. + Search { + /// Paths this call touched. + paths: Vec, + /// Text the call reported, when any. + output: Option, + }, + /// Retrieving external data. + Fetch { + /// Text the call reported, when any. + output: Option, + }, + /// Explicit reasoning surfaced as its own tool call. + Think { + /// Text the call reported, when any. + output: Option, + }, + /// Anything else, including tools the fold has no special rendering for. + Other { + /// ACP's tool kind, as its wire string. + #[serde(rename = "acpKind")] + acp_kind: String, + /// Text the call reported, when any. + output: Option, + /// The tool's input, when reported. + /// + /// Typed as `unknown` on the wire rather than a faithful JSON value: + /// `serde_json::Value` can hold a `Number` wide enough to lose + /// precision in `JSON.parse`, which specta refuses to export at all, + /// and this is opaque, pass-through input a reader treats as such + /// anyway. + #[specta(type = specta_typescript::Unknown)] + input: Option, + }, +} + +/// A tool call's paths, as the wire sees them. +fn wire_paths(paths: Vec) -> Vec { + paths + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect() +} + +impl From for ToolDetail { + fn from(detail: ModelToolDetail) -> Self { + match detail { + ModelToolDetail::Terminal { + command, + output, + exit_code, + } => Self::Terminal { + command, + // The escapes survive the fold; turning them into something a + // reader can see is the reader's business, not this crate's. + output: output.map(|output| output.0), + exit_code, + }, + ModelToolDetail::Edit { diffs } => Self::Edit { + diffs: diffs.into_iter().map(Into::into).collect(), + }, + ModelToolDetail::Read { paths } => Self::Read { + paths: wire_paths(paths), + }, + ModelToolDetail::Delete { paths } => Self::Delete { + paths: wire_paths(paths), + }, + ModelToolDetail::Move { paths } => Self::Move { + paths: wire_paths(paths), + }, + ModelToolDetail::Search { paths, output } => Self::Search { + paths: wire_paths(paths), + output, + }, + ModelToolDetail::Fetch { output } => Self::Fetch { output }, + ModelToolDetail::Think { output } => Self::Think { output }, + ModelToolDetail::Other { + kind, + output, + input, + } => Self::Other { + acp_kind: kind, + output, + input, + }, + } + } +} + +/// A file modification a tool reported, mirroring [`crate::domain::model::FileDiff`]. +#[derive(Serialize, Type)] +#[serde(rename_all = "camelCase")] +struct FileDiff { + /// The file that changed. + path: String, + /// Prior contents, absent when the file is new. + old_text: Option, + /// New contents. + new_text: String, +} + +impl From for FileDiff { + fn from(diff: ModelFileDiff) -> Self { + Self { + path: diff.path.to_string_lossy().into_owned(), + old_text: diff.old_text, + new_text: diff.new_text, + } + } +} + +/// One choice offered for a permission request, mirroring +/// [`crate::domain::model::PermissionOption`]. +#[derive(Serialize, Type)] +#[serde(rename_all = "camelCase")] +struct PermissionOption { + /// The id to report back when this option is chosen. + id: String, + /// Label to show. + name: String, + /// ACP's option kind, as its wire string. + kind: String, +} + +impl From for PermissionOption { + fn from(option: ModelPermissionOption) -> Self { + Self { + id: option.id, + name: option.name, + kind: permission_option_kind_wire(option.kind).to_owned(), + } + } +} + +/// [`PermissionOption::kind`]'s wire string, the vocabulary +/// `apps/web/src/lib/service-clients/service-agent-fold/generated/types.ts`'s `PermissionOption.kind` is +/// generated against. +fn permission_option_kind_wire(kind: PermissionOptionKind) -> &'static str { + match kind { + PermissionOptionKind::AllowOnce => "allow_once", + PermissionOptionKind::AllowAlways => "allow_always", + PermissionOptionKind::RejectOnce => "reject_once", + PermissionOptionKind::RejectAlways => "reject_always", + } +} + +/// How a permission request resolved, mirroring +/// [`crate::domain::model::PermissionOutcome`]. +#[derive(Serialize, Type)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum PermissionOutcome { + /// An option was chosen. + Selected { + /// The chosen option's id. + #[serde(rename = "optionId")] + option_id: String, + }, + /// The request was cancelled without a choice. + Cancelled, +} + +/// [`ModelPermissionOutcome`] as the wire sees it: whether an option was +/// chosen, collapsing every way it was not into absence. +/// +/// The wire does not yet distinguish *why* nothing was chosen - +/// [`ModelPermissionOutcome::Pending`], [`ModelPermissionOutcome::Errored`] +/// and [`ModelPermissionOutcome::Unrecognized`] all read as "no outcome" to a +/// reader today. That is a real loss (an errored or unrecognized request will +/// not resolve further, unlike a pending one), but widening the wire to say +/// so is a frontend change of its own, not implied by giving the fold's own +/// state machine a name for each case. +fn permission_outcome(outcome: ModelPermissionOutcome) -> Option { + match outcome { + ModelPermissionOutcome::Selected { option_id } => { + Some(PermissionOutcome::Selected { option_id }) + } + ModelPermissionOutcome::Cancelled => Some(PermissionOutcome::Cancelled), + ModelPermissionOutcome::Pending + | ModelPermissionOutcome::Errored + | ModelPermissionOutcome::Unrecognized => None, + } +} + +/// Why a turn stopped, mirroring [`crate::domain::model::StopReason`]. +#[derive(Serialize, Type)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum StopReason { + EndTurn, + MaxTokens, + MaxTurnRequests, + Refusal, + Cancelled, + /// A stop reason this fold does not model, as its wire string. + Other { + /// The wire string. + reason: String, + }, +} + +impl From for StopReason { + fn from(stop: ModelStopReason) -> Self { + match stop { + ModelStopReason::EndTurn => Self::EndTurn, + ModelStopReason::MaxTokens => Self::MaxTokens, + ModelStopReason::MaxTurnRequests => Self::MaxTurnRequests, + ModelStopReason::Refusal => Self::Refusal, + ModelStopReason::Cancelled => Self::Cancelled, + ModelStopReason::Other(reason) => Self::Other { reason }, + } + } +}