diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 354e8a60ca..55036776c6 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -40,11 +40,14 @@ jobs: # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. + # Dependabot-triggered runs get a separate secret scope, so this job has no API + # key and cannot review. workflow_call stays open for an explicit request. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && - github.event.pull_request.head.repo.fork == false + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.user.login != 'dependabot[bot]' ) permissions: contents: read diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml index d731faafd9..c83cbc90d3 100644 --- a/.github/workflows/discord-notification.yml +++ b/.github/workflows/discord-notification.yml @@ -13,7 +13,8 @@ on: jobs: notify_discord_when_pr_opened: - if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review') + # No thread is opened for Dependabot PRs, so nothing downstream may assume one. + if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review') && (github.event.pull_request.user.login != 'dependabot[bot]') uses: ./.github/workflows/shareable-discord-notification.yml with: PR_TITLE: ${{ github.event.pull_request.title }} @@ -28,7 +29,8 @@ jobs: DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} merge_success_emoji: - if: github.event.action == 'closed' + # Must match open_thread's exclusion: this reacts to a thread that was never created. + if: github.event.action == 'closed' && github.event.pull_request.user.login != 'dependabot[bot]' uses: ./.github/workflows/shareable-discord-notification.yml with: PR_STATUS: "merged" diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index a89275cfb5..a18dc28ff2 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -38,11 +38,14 @@ jobs: # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. + # Dependabot-triggered runs get a separate secret scope, so this job has no API + # key and cannot review. workflow_call stays open for an explicit request. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && - github.event.pull_request.head.repo.fork == false + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.user.login != 'dependabot[bot]' ) permissions: contents: read diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 4bfb5b4147..5e5c6a8c61 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -37,11 +37,14 @@ jobs: # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. + # Dependabot-triggered runs get a separate secret scope, so this job has no API + # key and cannot review. workflow_call stays open for an explicit request. if: | github.event_name == 'workflow_call' || ( (github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) && - github.event.pull_request.head.repo.fork == false + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.user.login != 'dependabot[bot]' ) permissions: contents: read diff --git a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json b/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json similarity index 58% rename from backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json rename to backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json index 5661c59faf..90566827e7 100644 --- a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json +++ b/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true", + "query": "SELECT COUNT(DISTINCT email) FROM usr WHERE is_service_account AND NOT disabled", "describe": { "columns": [ { @@ -16,5 +16,5 @@ null ] }, - "hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf" + "hash": "3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075" } diff --git a/backend/.sqlx/query-e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43.json b/backend/.sqlx/query-e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43.json deleted file mode 100644 index accf6b0a49..0000000000 --- a/backend/.sqlx/query-e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(DISTINCT email) FROM usr\n WHERE is_service_account AND NOT disabled AND workspace_id NOT LIKE 'wm-fork%'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 80c21f89c1..211848b65a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -0914cece0eb508150898022a0d78da9caa0a1c6d +ed5a367b4def5280c3a5e1090e7abfa099a01042 diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index edd2040143..54886e2568 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -69,7 +69,7 @@ use windmill_common::{ user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, utils::{ - http_get_from_hub, not_found_if_none, paginate, paginate_optional, + check_proper_path, http_get_from_hub, not_found_if_none, paginate, paginate_optional, query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath, }, variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, @@ -2506,6 +2506,7 @@ async fn create_app_internal<'a>( // inside process_app_multipart!, so checking after this call would leave a // denied app committed in the DB. check_scopes(&authed, || format!("apps:write:{}", &app.path))?; + check_proper_path(&app.path)?; validate_frontend_sdk_scopes(&app.policy)?; if raw_app { validate_raw_app_path_keys(&app.value.0)?; @@ -3252,6 +3253,7 @@ async fn create_app_raw_source( // Before the compile, which costs a job on a worker: it must not run for a // path already taken, nor for one the caller can't write. `create_app_internal` // rejects both, but only after the sources have been built. + check_proper_path(&path)?; if app_exists(&db, &w_id, &path).await? { return Err(Error::BadRequest(format!("App {path} already exists"))); } @@ -3462,6 +3464,9 @@ async fn update_app_internal<'a>( // the token's write scope, not just the source path. if let Some(npath) = ns.path.as_deref() { check_scopes(&authed, || format!("apps:write:{}", npath))?; + if npath != path { + check_proper_path(npath)?; + } } if raw_app { diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index e29113ecd2..ea79aaa572 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -281,8 +281,29 @@ enum ArchiveImpl { Tar(tokio_tar::Builder), } +/// Entry paths come from item paths stored in the workspace; an absolute or +/// drive-prefixed path, or a parent segment, would make extraction write outside +/// the target directory. Win32 strips trailing dots and spaces from a segment, +/// so `.. ` resolves to `..` there: any segment made only of those is refused. +/// A colon matters only in the first segment (a drive prefix); later segments, +/// such as a data table name, may carry one. +fn check_archive_entry_path(path: &str) -> Result<()> { + let is_dot_segment = |seg: &str| !seg.is_empty() && seg.chars().all(|c| c == '.' || c == ' '); + let first = path.split(['/', '\\']).next().unwrap_or_default(); + if path.starts_with(['/', '\\']) + || first.contains(':') + || path.split(['/', '\\']).any(is_dot_segment) + { + return Err(Error::internal_err(format!( + "refusing to write archive entry with path traversal: {path}" + ))); + } + Ok(()) +} + impl ArchiveImpl { async fn write_to_archive(&mut self, content: &str, path: &str) -> Result<()> { + check_archive_entry_path(path)?; match self { ArchiveImpl::Tar(t) => { let bytes = content.as_bytes(); @@ -1647,7 +1668,9 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts: row.mute_critical_alerts, color: row.color.clone(), operator_settings: row.operator_settings.clone(), - datatable: windmill_common::workspaces::strip_datatable_permissions(row.datatable.clone()), + datatable: windmill_common::workspaces::strip_datatable_permissions( + row.datatable.clone(), + ), slack_team_id: row.slack_team_id.clone(), slack_name: row.slack_name.clone(), slack_command_script: row.slack_command_script.clone(), @@ -1792,6 +1815,41 @@ pub(crate) async fn tarball_workspace( Ok((headers, body)) } +#[cfg(test)] +mod archive_entry_path_tests { + use super::check_archive_entry_path; + + #[test] + fn rejects_traversal_and_absolute_paths() { + for ok in [ + "u/admin/app.app.json", + "f/x/a..b.script.json", + "settings.yaml", + "migrations/datatable/foo:bar/20260617120000_name.up.sql", + ] { + assert!( + check_archive_entry_path(ok).is_ok(), + "{ok} should be accepted" + ); + } + for bad in [ + "f/x/../../evil.app.json", + "../evil", + "/etc/evil", + "f\\..\\evil", + "f/x/.. /.. /evil.app.json", + "f/x/.../evil", + "C:/evil", + "\\evil", + ] { + assert!( + check_archive_entry_path(bad).is_err(), + "{bad} should be rejected" + ); + } + } +} + #[cfg(test)] mod fork_export_tests { use super::*; diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 29fc9e99f3..b247c66532 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -245,6 +245,9 @@ lazy_static::lazy_static! { /// Reject a path the `proper_id` constraint would reject anyway, so the caller /// gets a plain 400 instead of the raw Postgres constraint-violation string, /// which names the table and constraint and echoes the input back. +/// +/// `app` and the trigger tables have no such constraint: for them this check is +/// the only authority, so loosening it lets malformed paths be stored. pub fn check_proper_path(path: &str) -> Result<()> { // The column is varchar(255); without this an over-long but well-formed path // still reaches Postgres and leaks the same kind of message back. diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 01ddfd4a81..539c8e43d2 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -13,6 +13,7 @@ use windmill_common::global_settings::{validate_allowed_origins, HTTP_ROUTE_WORK use windmill_common::{ db::UserDB, error::{Error, Result}, + utils::check_proper_path, worker::CLOUD_HOSTED, DB, }; @@ -269,6 +270,7 @@ pub async fn create_many_http_triggers( check_scopes(&authed, || { format!("http_triggers:write:{}", &new_http_trigger.base.path) })?; + check_proper_path(&new_http_trigger.base.path)?; // This route inserts directly, bypassing the shared create handler. // `error_wrapper` would turn the rejection into a 500. diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 66d6770354..ba40ed2c48 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -22,7 +22,7 @@ use windmill_common::{ fetch_draft_only_list_rows, overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, }, - utils::{paginate, Pagination, StripPath}, + utils::{check_proper_path, paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, DB, }; @@ -547,6 +547,7 @@ async fn create_trigger( &new_trigger.base.path ) })?; + check_proper_path(&new_trigger.base.path)?; if *CLOUD_HOSTED && !T::IS_ALLOWED_ON_CLOUD { return Err(Error::BadRequest(format!( @@ -817,6 +818,9 @@ async fn update_trigger( &edit_trigger.base.path ) })?; + if edit_trigger.base.path != path { + check_proper_path(&edit_trigger.base.path)?; + } edit_trigger.error_handling.validate()?; diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte index 0388ab0845..ad84cc263b 100644 --- a/frontend/src/lib/components/AgentResultDisplay.svelte +++ b/frontend/src/lib/components/AgentResultDisplay.svelte @@ -5,7 +5,6 @@ import AgentTrace from './AgentTrace.svelte' import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte' import LabeledDivider from './LabeledDivider.svelte' - import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte' import { buildAgentTrace, splitFinalAnswer } from './agentTrace' import { runPane } from './agentScroll' import { createBottomSticker } from './stickToBottom' @@ -32,9 +31,8 @@ // The turn that produced `output` is not a trace row: the output block below is // that same text, and printing it twice in one scroll reads as the agent having - // answered itself. Its citations move down with it. - let answer = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output)) - let trace = $derived(answer.trace) + // answered itself. Its citations stay in the trace, on the search that found them. + let trace = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output)) let reasoning = $derived(result.reasoning?.trim()) let reasoningExpanded = $state(false) @@ -85,11 +83,6 @@ {:else} {@render structuredOutput(result.output)} {/if} - {#if answer.sources} -
- -
- {/if} diff --git a/frontend/src/lib/components/AgentTrace.svelte b/frontend/src/lib/components/AgentTrace.svelte index 91b87fea67..7f2a90e621 100644 --- a/frontend/src/lib/components/AgentTrace.svelte +++ b/frontend/src/lib/components/AgentTrace.svelte @@ -6,6 +6,7 @@ import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte' import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte' import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte' + import { webSearchResultOf, type WebSearchResult } from './copilot/chat/shared' import GfmMarkdown from './GfmMarkdown.svelte' import type { AgentTraceEntry } from './agentTrace' import { SvelteMap, SvelteSet } from 'svelte/reactivity' @@ -21,11 +22,14 @@ let { entries, workspaceId }: Props = $props() let expanded = new SvelteSet() + // A search opens on its sources, so for it the reader's toggle records a close. + let collapsedSearches = new SvelteSet() // Row state is keyed by position, which means nothing once the viewer is handed // another run: row 0 would stay open showing the previous run's job. $effect(() => { entries expanded.clear() + collapsedSearches.clear() jobs.clear() }) // A tool's own job holds what the envelope does not: its logs, how long it @@ -67,12 +71,18 @@ return typeof job === 'object' ? job : undefined } - function toolLabel(entry: Extract): string { - const job = jobOf(entry.jobId) - const duration = job?.['duration_ms'] - return duration === undefined - ? entry.name - : `${entry.name} · ${msToReadableTimeShort(duration)}` + function toolLabel( + entry: Extract, + search: WebSearchResult | undefined + ): string { + const duration = jobOf(entry.jobId)?.['duration_ms'] + return [ + entry.name, + search?.query !== undefined ? `"${search.query}"` : undefined, + duration !== undefined ? msToReadableTimeShort(duration) : undefined + ] + .filter((part) => part !== undefined) + .join(' · ') } @@ -81,25 +91,29 @@ {#if entry.kind === 'assistant'}
- {#if entry.sources} -
- -
- {/if}
{:else if entry.kind === 'search'} - + {}} - /> + label="Searched the web" + expanded={Boolean(entry.sources) && !collapsedSearches.has(index)} + toggleable={Boolean(entry.sources)} + onToggle={() => { + if (!collapsedSearches.delete(index)) collapsedSearches.add(index) + }} + > + {#if entry.sources} + + {/if} + {:else} {@const job = jobOf(entry.jobId)} + {@const failed = job?.type === 'CompletedJob' && !job.success} + {@const search = webSearchResultOf(entry.result)} toggle(index, entry)} contentClass="space-y-3" @@ -110,11 +124,15 @@ {#if job?.logs} {/if} - + {#if search && !failed} + + {:else} + + {/if} {#if entry.resourcePath}
diff --git a/frontend/src/lib/components/agentTrace.test.ts b/frontend/src/lib/components/agentTrace.test.ts index 3297a99d82..7d1dcf3429 100644 --- a/frontend/src/lib/components/agentTrace.test.ts +++ b/frontend/src/lib/components/agentTrace.test.ts @@ -45,7 +45,7 @@ describe('buildAgentTrace', () => { result: '{"eu-central-1":0.184}', jobId: '0199-job' }, - { kind: 'assistant', content: 'eu-central-1 is down.', sources: undefined } + { kind: 'assistant', content: 'eu-central-1 is down.' } ]) }) @@ -76,8 +76,8 @@ describe('buildAgentTrace', () => { // The worker splits a search the same way: a `tool` message tagged web_search // carrying a constant sentence, then the assistant turn that carries the - // citations. The search row therefore has nothing of its own to show. - it('records the search and puts its citations on the turn that follows', () => { + // citations, which move onto the search so it shows what it found. + it('puts the citations of the turn that follows on the search', () => { const entries = buildAgentTrace([ { role: 'tool', @@ -92,12 +92,11 @@ describe('buildAgentTrace', () => { } ]) expect(entries).toEqual([ - { kind: 'search' }, { - kind: 'assistant', - content: 'Postgres 17 changed the default.', + kind: 'search', sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] - } + }, + { kind: 'assistant', content: 'Postgres 17 changed the default.' } ]) }) @@ -118,7 +117,7 @@ describe('buildAgentTrace', () => { }) describe('splitFinalAnswer', () => { - it('moves the answering turn out of the trace, with its citations', () => { + it('moves the answering turn out of the trace, leaving its citations on the search', () => { const entries = buildAgentTrace([ { role: 'tool', content: 'Used websearch tool', agent_action: { type: 'web_search' } }, { @@ -128,10 +127,12 @@ describe('splitFinalAnswer', () => { agent_action: { type: 'message' } } ]) - expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual({ - trace: [{ kind: 'search' }], - sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] - }) + expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual([ + { + kind: 'search', + sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] + } + ]) }) // A run whose last turn returned a tool call and no text leaves its answer @@ -152,12 +153,12 @@ describe('splitFinalAnswer', () => { } } ]) - expect(splitFinalAnswer(entries, 'Let me check.').trace).toEqual([entries[1]]) + expect(splitFinalAnswer(entries, 'Let me check.')).toEqual([entries[1]]) }) it('leaves the trace whole when the output is not a turn of its own', () => { const entries = buildAgentTrace(messages) - expect(splitFinalAnswer(entries, { rows: 3 })).toEqual({ trace: entries }) + expect(splitFinalAnswer(entries, { rows: 3 })).toEqual(entries) }) }) diff --git a/frontend/src/lib/components/agentTrace.ts b/frontend/src/lib/components/agentTrace.ts index ba0cb78d4e..392e8b00c9 100644 --- a/frontend/src/lib/components/agentTrace.ts +++ b/frontend/src/lib/components/agentTrace.ts @@ -8,10 +8,10 @@ import type { AgentMessage } from './aiAgentResult' * are shown as inputs, while the trace is what the agent did with them. */ export type AgentTraceEntry = - | { kind: 'assistant'; content: string; sources?: WebSearchSource[] } - /** A search records only that one happened: the worker writes a constant - * sentence, and the citations ride on the assistant turn that follows. */ - | { kind: 'search' } + | { kind: 'assistant'; content: string } + /** The worker records only that a search happened. Its citations ride on the assistant + * turn that follows, and are moved here so the search shows what it found. */ + | { kind: 'search'; sources?: WebSearchSource[] } | { kind: 'tool' name: string @@ -99,8 +99,14 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] { continue } const content = contentText(message.content) + // The worker writes a provider call's search right before that call's answer, and + // only a call that searched carries annotations. + const previous = entries[entries.length - 1] + if (message.role === 'assistant' && previous?.kind === 'search' && !previous.sources) { + previous.sources = sourcesOf(message) + } if (message.role === 'assistant' && content !== '') { - entries.push({ kind: 'assistant', content, sources: sourcesOf(message) }) + entries.push({ kind: 'assistant', content }) } } return entries @@ -108,27 +114,21 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] { /** * Separates the turn that produced the output from the rest of the trace, so the - * answer is rendered once with the citations that belong to it. Found by content - * and searched from the end: a run whose last turn returned a tool call leaves its - * answer mid-trace, where inspecting only the final entry prints it twice. + * answer is rendered once. Found by content and searched from the end: a run whose + * last turn returned a tool call leaves its answer mid-trace, where inspecting only + * the final entry prints it twice. */ -export function splitFinalAnswer( - entries: AgentTraceEntry[], - output: unknown -): { trace: AgentTraceEntry[]; sources?: WebSearchSource[] } { +export function splitFinalAnswer(entries: AgentTraceEntry[], output: unknown): AgentTraceEntry[] { if (typeof output !== 'string') { // A schema-shaped output is rendered by the result viewer itself and matches // no turn, so the whole trace stands. - return { trace: entries } + return entries } for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] if (entry.kind === 'assistant' && entry.content === output) { - // The citations are an annotation on that turn, so moving the turn without - // them would leave a run that shows a web search ran and no source for - // what it answered. - return { trace: [...entries.slice(0, i), ...entries.slice(i + 1)], sources: entry.sources } + return [...entries.slice(0, i), ...entries.slice(i + 1)] } } - return { trace: entries } + return entries } diff --git a/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte new file mode 100644 index 0000000000..6f74ff7a80 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte @@ -0,0 +1,170 @@ + + + + +
+ {#each visibleRows as row, index ('key' in row ? `${row.kind}:${row.key}` : `line:${row.oldLine}:${row.newLine}:${index}`)} + {#if row.kind === 'collapsed' || row.kind === 'omitted'} + + {:else} +
+ {row.oldLine ?? ''} + + {row.newLine ?? ''}{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''} + + + {#each row.changedRanges ?? [] as range} + + {/each} + {@html highlightedLine(row) || ' '} + +
+ {/if} + {/each} +
+ + diff --git a/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte new file mode 100644 index 0000000000..a2ab9d341c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte @@ -0,0 +1,103 @@ + + +{#snippet headerRight()} +
+ {#if counts && (counts.added > 0 || counts.removed > 0)} + + +{counts.added} + −{counts.removed} + + {/if} + {#if showPreviewChip && message.previewCard} + + {/if} +
+{/snippet} + + (toggled = { id: toggleKey, open: !expanded })} + toggleable={hasBody} + shimmer={isRunning} + class={message.isQueued && !message.error + ? 'opacity-60 hover:opacity-100 transition-opacity' + : ''} + labelClass="truncate" + contentClass="p-0 overflow-hidden space-y-0" + {headerRight} +> + {#if diff && hasDiff && expanded} + + {/if} + {#if message.error} +
+ {message.error} +
+ {/if} +
diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 5405a6ea25..d5fee5a4d9 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -26,7 +26,7 @@ import { getChatViewHost } from './chatViewHost' const chatHost = getChatViewHost() - import { isActiveUserQuestion, type ToolDisplayMessage } from './shared' + import { isActiveUserQuestion, webSearchResultOf, type ToolDisplayMessage } from './shared' import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' import { twMerge } from 'tailwind-merge' import { slide } from 'svelte/transition' @@ -40,6 +40,8 @@ import ToolPreviewCard from './ToolPreviewCard.svelte' import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte' import RunScriptCard from './RunScriptCard.svelte' + import ToolDiffCard from './ToolDiffCard.svelte' + import { hasToolCodeDiff } from './toolCodeDiff' import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte' import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte' import McpServerIcon from '$lib/components/mcp/McpServerIcon.svelte' @@ -132,6 +134,7 @@ // A call that inspected a run rather than starting one gets the same card, bound to // the job it named — what happened in a run reads the same either way. const isRunCard = $derived(Boolean(message.runForm || message.inspectedRun)) + const isDiffCard = $derived(Boolean(message.codeDiff) || hasToolCodeDiff(message.toolName)) // The preview chip sits on the header row (to the right of the tool-call text); // shown once the tool settled, never while loading/erroring/awaiting confirmation. @@ -140,6 +143,18 @@ message.previewCard && !message.isLoading && !message.error && !message.needsConfirmation ) ) + + // A provider-side search sets `webSearchSources` and words its own header; any other tool + // gets the same card by returning the web search result shape. + const searchResult = $derived( + message.webSearchSources || message.error ? undefined : webSearchResultOf(message.result) + ) + const sources = $derived(message.webSearchSources ?? searchResult?.sources) + const label = $derived( + searchResult?.query !== undefined + ? `${message.content} · "${searchResult.query}"` + : message.content + ) {#if activeUserQuestion} @@ -158,6 +173,8 @@
{:else if isRunCard} +{:else if isDiffCard} + {:else if planState} @@ -281,7 +298,7 @@ weight alone: queued calls (waiting their turn behind the executing tool) are faded, the running one sweeps, a settled one is plain. --> (isExpanded = !isExpanded)} toggleable={detailsAvailable || message.isStreamingArguments === true} @@ -343,8 +360,8 @@ {#if visibleActions.length > 0} - {:else if message.webSearchSources?.length && !message.error} - + {:else if sources?.length && !message.error} + {:else} import { Globe } from 'lucide-svelte' import { SvelteSet } from 'svelte/reactivity' - import type { WebSearchSource } from './shared' + import { isRenderableSourceUrl, type WebSearchSource } from './shared' import { isOfflineReplay } from '$lib/components/recording/offlineReplay.svelte' interface Props { sources: WebSearchSource[] + /** Whether a hostname may be sent to the favicon service. False for the sources a + * workspace's own tool returned: those can name internal services, and disclosing + * them to a third party is the tool author's call to make, not this card's. */ + favicons?: boolean } - let { sources }: Props = $props() + let { sources, favicons = true }: Props = $props() - // The URLs come from the provider's response: only render absolute http(s) - // ones — anything else (javascript:, data:, relative) must not become an - // href. Also dedupes; providers can surface the same page several times. + // The URLs come from the provider's response or from a tool's own result: only render + // absolute http(s) ones — anything else (javascript:, data:, relative) must not become + // an href. Also dedupes; providers can surface the same page several times. const uniqueSources = $derived( Array.from( - new Map( - sources - .filter((s) => { - try { - return ['http:', 'https:'].includes(new URL(s.url).protocol) - } catch { - return false - } - }) - .map((s) => [s.url, s]) - ).values() + new Map(sources.filter((s) => isRenderableSourceUrl(s.url)).map((s) => [s.url, s])).values() ) ) @@ -43,7 +37,7 @@ // to issue none — a recording comes from an arbitrary origin, so its cited // hostnames must not leak from a viewer's browser either. Degrades to the same // Globe the blocked/failed case already uses. - const noFavicons = $derived(isOfflineReplay()) + const noFavicons = $derived(!favicons || isOfflineReplay()) // Favicons come from Google's public favicon service, which discloses each // consulted hostname to a third party from the user's browser — an accepted @@ -62,6 +56,9 @@
{#each uniqueSources as source (source.url)} {@const hostname = hostnameOf(source.url)} + + {@const title = source.title?.trim() || undefined} failedFavicons.add(hostname)} /> {/if} - {source.title ?? hostname} - {#if source.title} + {title ?? hostname} + {#if title} {hostname} {/if} diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 7a86e0ba76..a1755bcaa9 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2024,6 +2024,24 @@ describe('global AI tools', () => { }) }) + it('records a creation diff for a new script draft', async () => { + const statuses: any[] = [] + + await callGlobalTool( + 'write_script', + { + path: 'f/scripts/new-script', + language: 'python3', + content: 'print("hello")' + }, + { ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) } + ) + + expect(statuses).toContainEqual({ + codeDiff: { before: '', after: 'print("hello")', lang: 'python' } + }) + }) + it('tells a code app from a drag-and-drop app', async () => { vi.mocked(AppService.listApps).mockResolvedValueOnce([ { path: 'f/apps/code', summary: 'Code app', raw_app: true }, @@ -3080,12 +3098,17 @@ describe('global AI tools', () => { kind: 'script' } as any) - await callGlobalTool('write_script', { - path: 'f/scripts/existing', - summary: 'new summary', - language: 'bun', - content: 'new content' - }) + const statuses: any[] = [] + await callGlobalTool( + 'write_script', + { + path: 'f/scripts/existing', + summary: 'new summary', + language: 'bun', + content: 'new content' + }, + { ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) } + ) expect( getBackendDraft('script', 'f/scripts/existing', { workspace: WORKSPACE }) @@ -3097,6 +3120,9 @@ describe('global AI tools', () => { content: 'new content', language: 'bun' }) + expect(statuses).toContainEqual({ + codeDiff: { before: 'old deployed content', after: 'new content', lang: 'typescript' } + }) }) describe('stale-draft deploy guard and rebase', () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 5b0490c724..a4581bd173 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -137,8 +137,10 @@ import { type RunFormDisplay, type Tool, type ToolCallbacks, + type ToolCodeDiff, type ToolDisplayAction } from '../shared' +import { scriptLangToEditorLang } from '$lib/scripts' import { searchDocsTool, readDocsPageTool } from '../docs/core' import { createDbSchemaTool } from '../script/core' import type { ContextElement } from '../context' @@ -5029,10 +5031,12 @@ function finishAppDraftWrite( function finishDraftWrite( result: DraftPersistResult, existed: boolean, - ctx: WriteDraftCtx + ctx: WriteDraftCtx, + codeDiff?: ToolCodeDiff ): string { const failure = draftWriteFailure(result, ctx) if (failure) return failure + if (codeDiff) ctx.toolCallbacks.setToolStatus(ctx.toolId, { codeDiff }) ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath) maybeAttachPreviewCard(ctx, result.itemKind, result.item.path) const stored = result.item @@ -5078,7 +5082,11 @@ async function writeDraft( path: string, args: A, ctx: WriteDraftCtx, - opts: { triggerKind?: TriggerKind; override?: boolean } = {} + opts: { + triggerKind?: TriggerKind + override?: boolean + codeDiff?: ToolCodeDiff | ((base: T | undefined, draft: T) => ToolCodeDiff | undefined) + } = {} ): Promise { const { workspace } = ctx startDraftWrite(ctx, type, path) @@ -5097,7 +5105,8 @@ async function writeDraft( triggerKind: opts.triggerKind, force: opts.override }) - return finishDraftWrite(result, existed, ctx) + const codeDiff = typeof opts.codeDiff === 'function' ? opts.codeDiff(base, draft) : opts.codeDiff + return finishDraftWrite(result, existed, ctx, codeDiff) } type ScriptDraftArgs = { @@ -5150,8 +5159,21 @@ const SCRIPT_SPEC: WriteSpec = { } } -function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise { - return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { override: args.override }) +function writeScriptDraft( + args: ScriptDraftArgs, + ctx: WriteDraftCtx, + codeDiff?: ToolCodeDiff +): Promise { + return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { + override: args.override, + codeDiff: + codeDiff ?? + ((base, draft) => ({ + before: base?.content ?? '', + after: draft.content, + lang: scriptLangToEditorLang(draft.language) + })) + }) } type FlowDraftArgs = { @@ -5368,7 +5390,8 @@ async function editScript( language: base.language, content: updated }, - ctx + ctx, + { before: base.content, after: updated, lang: scriptLangToEditorLang(base.language) } ) } diff --git a/frontend/src/lib/components/copilot/chat/script/core.test.ts b/frontend/src/lib/components/copilot/chat/script/core.test.ts new file mode 100644 index 0000000000..f235ae3796 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/script/core.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('monaco-editor', () => ({ + editor: {}, + languages: {}, + KeyCode: {}, + Uri: { parse: (value: string) => ({ toString: () => value }) }, + MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 } +})) +vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({ + getTypeScriptWorker: async () => async () => ({}), + typescriptVersion: 'test' +})) +vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) })) +vi.mock('$lib/components/vscode', () => ({})) + +import { editCodeToolWithDiff } from './core' + +describe('editCodeToolWithDiff', () => { + it('records the complete script diff after applying replacements', async () => { + const statuses: unknown[] = [] + const applyCode = vi.fn(async () => {}) + + await editCodeToolWithDiff.fn({ + args: { diffs: [{ old_string: 'return 1', new_string: 'return 2' }] }, + helpers: { + getScriptOptions: () => ({ + code: 'export function main() {\n\treturn 1\n}', + lang: 'bun', + path: 'f/example', + args: {} + }), + applyCode + }, + toolCallbacks: { + setToolStatus: (_toolId, status) => statuses.push(status) + }, + toolId: 'edit-1' + }) + + expect(applyCode).toHaveBeenCalledTimes(2) + expect(statuses.at(-1)).toMatchObject({ + codeDiff: { + before: 'export function main() {\n\treturn 1\n}', + after: 'export function main() {\n\treturn 2\n}', + lang: 'typescript' + } + }) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 622f182eae..0c8b3f9a0e 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -27,6 +27,7 @@ import type { ReviewChangesOpts } from '../monaco-adapter' import { copilotInfo, getCurrentModel } from '$lib/aiStore' import { get } from 'svelte/store' import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata' +import { scriptLangToEditorLang } from '$lib/scripts' import { getScriptPrompt, getWorkflowAsCodePrompt } from '$system_prompts' // Score threshold for npm packages search filtering @@ -826,7 +827,12 @@ export const editCodeToolWithDiff: Tool = { toolCallbacks.setToolStatus(toolId, { content: `Code changes applied`, - result: 'Success' + result: 'Success', + codeDiff: { + before: oldCode, + after: updatedCode, + lang: scriptLangToEditorLang(scriptOptions.lang) + } }) return `Applied changes to the script editor.` } catch (error) { @@ -878,7 +884,12 @@ export const editCodeTool: Tool = { toolCallbacks.setToolStatus(toolId, { content: 'Code changes applied', - result: 'Success' + result: 'Success', + codeDiff: { + before: oldCode, + after: args.code, + lang: scriptLangToEditorLang(scriptOptions.lang) + } }) return 'Code has been applied to the script editor.' } catch (error) { diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 005a7666db..fb61f3a912 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { DisplayMessage, ToolDisplayMessage } from './shared' -import { openItemPreviewAction } from './shared' +import { openItemPreviewAction, webSearchResultOf } from './shared' vi.mock('monaco-editor', () => ({ editor: {} @@ -1869,3 +1869,38 @@ describe('processToolCall confirmation hooks', () => { expect(tool.fn).toHaveBeenCalled() }) }) + +// Any tool gets the web search card by returning this shape. The card renders urls and +// titles only, so a result carrying anything more must keep its JSON pane. +describe('webSearchResultOf', () => { + it('reads the shape, from the value or its JSON text', () => { + const result = { sources: [{ url: 'https://a.dev', title: 'A' }], query: 'a' } + expect(webSearchResultOf(result)).toEqual(result) + expect(webSearchResultOf(JSON.stringify(result))).toEqual(result) + }) + + // A Python tool serializes an absent optional as null, and the card has nothing to tell + // its author why an almost-right result fell back to JSON. + it('reads null on an optional field as absent', () => { + expect( + webSearchResultOf({ sources: [{ url: 'https://a.dev', title: null }], query: null }) + ).toEqual({ + sources: [{ url: 'https://a.dev', title: undefined }], + query: undefined + }) + }) + + it.each([ + ['an extra key', { sources: [{ url: 'https://a.dev' }], summary: 'x' }], + ['an extra source field', { sources: [{ url: 'https://a.dev', snippet: 'x' }] }], + ['a source without url', { sources: [{ title: 'A' }] }], + // The card renders no relative or javascript: link, so a result whose urls it would + // drop keeps its own JSON rather than showing an empty source list. + ['a relative url', { sources: [{ url: '/docs/pg17', title: 'PG 17' }] }], + ['a javascript: url', { sources: [{ url: 'javascript:alert(1)' }] }], + ['no sources', { sources: [] }], + ['a bare list of links', [{ url: 'https://a.dev' }]] + ])('rejects %s', (_, result) => { + expect(webSearchResultOf(result)).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index f58ed3b6d3..83a51b527c 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -610,6 +610,84 @@ export type WebSearchSource = { title?: string } +export type ToolCodeDiff = { + before: string + after: string + /** Monaco language id. */ + lang: string +} + +/** The result shape any tool returns to have it rendered as a web search card. */ +export type WebSearchResult = { + sources: WebSearchSource[] + query?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** A link the card can render: the source list drops everything else, so a result whose + * urls are relative or `javascript:` would show as an empty list in place of its own output. */ +export function isRenderableSourceUrl(url: string): boolean { + try { + return ['http:', 'https:'].includes(new URL(url).protocol) + } catch { + return false + } +} + +/** An absent optional field reaches JSON as `null` from a Python tool and as nothing from a + * TypeScript one, so both read as absent. */ +function isOptionalString(value: unknown): boolean { + return value === null || typeof value === 'string' +} + +function isWebSearchSource(value: unknown): value is { url: string; title?: unknown } { + return ( + isRecord(value) && + typeof value.url === 'string' && + isRenderableSourceUrl(value.url) && + Object.entries(value).every( + ([key, v]) => key === 'url' || (key === 'title' && isOptionalString(v)) + ) + ) +} + +/** + * A tool result as a web search, or undefined when it is anything else. The keys must be + * exactly `sources` and an optional `query`: the card renders only urls and titles, so a + * result carrying more would lose it. Accepts the result as its JSON text too. + */ +export function webSearchResultOf(result: unknown): WebSearchResult | undefined { + if (typeof result === 'string') { + try { + result = JSON.parse(result) + } catch { + return undefined + } + } + if (!isRecord(result) || !Array.isArray(result.sources) || result.sources.length === 0) { + return undefined + } + const sources = result.sources + if ( + !sources.every(isWebSearchSource) || + !Object.entries(result).every( + ([key, v]) => key === 'sources' || (key === 'query' && isOptionalString(v)) + ) + ) { + return undefined + } + return { + sources: sources.map((source) => ({ + url: source.url, + title: typeof source.title === 'string' ? source.title : undefined + })), + query: typeof result.query === 'string' ? result.query : undefined + } +} + export type ToolDisplayMessage = { role: 'tool' tool_call_id: string @@ -654,6 +732,9 @@ export type ToolDisplayMessage = { * always-visible card that opens (or focuses) the item's preview in the * session side panel. Set only for session chats — the side panel is their surface. */ previewCard?: { kind: PreviewCardKind; path: string } + /** Whole text on both sides of an edit this call saved. Kept in full rather than as a + * reference: drafts are overwritten in place, so the sides cannot be fetched back later. */ + codeDiff?: ToolCodeDiff planArtifactId?: string /** The version this card's proposal wrote, so a card scrolled far up still opens the plan * it proposed rather than what the document became. */ diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts new file mode 100644 index 0000000000..5077585a4f --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from 'vitest' +import hljs from 'highlight.js/lib/core' +import { + argumentsCarryWholeFile, + diffLineCounts, + hasToolCodeDiff, + toolCodeDiff, + toolDiffLines, + visibleToolDiffRows +} from './toolCodeDiff' +import { highlightedSourceLines } from './toolCodeDiffHighlight' +import { TOOL_CODE_DIFF_LANGUAGES, toolCodeDiffLanguage } from './toolCodeDiffLanguage' +import type { ToolDisplayMessage } from './shared' + +function message(overrides: Partial): ToolDisplayMessage { + return { + role: 'tool', + tool_call_id: 'call-1', + content: 'Updated script "f/example" as a draft', + ...overrides + } +} + +describe('toolCodeDiff', () => { + it('uses the saved full script sides after an edit succeeds', () => { + const codeDiff = { + before: 'const before = true\n', + after: 'const after = true\n', + lang: 'typescript' + } + + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: { old_string: 'before', new_string: 'after' }, + codeDiff + }) + ) + ).toBe(codeDiff) + }) + + it('uses edit arguments until the full script sides are available', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: { old_string: 'before', new_string: 'after' } + }) + ) + ).toEqual({ before: 'before', after: 'after', lang: 'plaintext' }) + }) + + it('renders streamed edit arguments before their JSON object is complete', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: '{"old_string":"before\\nline","new_string":"after' + }) + ) + ).toEqual({ before: 'before\nline', after: 'after', lang: 'plaintext' }) + }) + + it('uses each replacement from an in-editor edit while its arguments stream', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_code', + parameters: { + diffs: [ + { old_string: 'first old', new_string: 'first new' }, + { old_string: 'second old', new_string: 'second new' } + ] + } + }) + ) + ).toEqual({ + before: 'first old\nsecond old', + after: 'first new\nsecond new', + lang: 'plaintext' + }) + }) + + it('uses each replacement while in-editor edit arguments are incomplete', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_code', + parameters: + '{"diffs":[{"old_string":"first old","new_string":"first new"},{"old_string":"second old","new_string":"second new' + }) + ) + ).toEqual({ + before: 'first old\nsecond old', + after: 'first new\nsecond new', + lang: 'plaintext' + }) + }) + + it('shows a streamed full-code editor update without replacements', () => { + expect( + toolCodeDiff(message({ toolName: 'edit_code', parameters: '{"code":"console.log(1)' })) + ).toEqual({ before: '', after: 'console.log(1)', lang: 'plaintext' }) + }) + + it('shows streamed full-script updates before the saved diff is available', () => { + expect( + toolCodeDiff( + message({ toolName: 'write_script', parameters: '{"content":"print(\\\"hello\\\")' }) + ) + ).toEqual({ before: '', after: 'print("hello")', lang: 'plaintext' }) + }) + + it('only marks tools with a defined argument diff as diff-capable', () => { + expect(hasToolCodeDiff('edit_script')).toBe(true) + expect(hasToolCodeDiff('edit_code')).toBe(true) + expect(hasToolCodeDiff('write_script')).toBe(true) + expect(hasToolCodeDiff('__proto__')).toBe(false) + expect(hasToolCodeDiff('toString')).toBe(false) + expect(hasToolCodeDiff(undefined)).toBe(false) + }) + + it('ignores tool names inherited from Object.prototype', () => { + expect(toolCodeDiff(message({ toolName: 'constructor', parameters: {} }))).toBeUndefined() + }) + + it('counts added and removed lines independently', () => { + expect( + diffLineCounts({ + before: 'one\ntwo\nthree\n', + after: 'one\nnew\nthree\nfour\n', + lang: 'plaintext' + }) + ).toEqual({ + added: 2, + removed: 1 + }) + }) + + it('keeps a final newline as a diffable line', () => { + expect(toolDiffLines({ before: 'one', after: 'one\n', lang: 'plaintext' })).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { kind: 'added', content: '', newLine: 2 } + ]) + }) + + it('does not add a final-newline row when creating or deleting a file', () => { + expect(toolDiffLines({ before: '', after: 'one\n', lang: 'plaintext' })).toEqual([ + { kind: 'added', content: 'one', newLine: 1, changedRanges: [{ start: 0, length: 3 }] } + ]) + expect(toolDiffLines({ before: 'one\n', after: '', lang: 'plaintext' })).toEqual([ + { kind: 'removed', content: 'one', oldLine: 1, changedRanges: [{ start: 0, length: 3 }] } + ]) + }) + + it('keeps both gutters correct across removed and added lines', () => { + expect( + toolDiffLines({ + before: 'one\ntwo\nthree\n', + after: 'one\nnew\nthree\nfour\n', + lang: 'plaintext' + }) + ).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { + kind: 'removed', + content: 'two', + oldLine: 2, + newLine: undefined, + changedRanges: [{ start: 0, length: 3 }] + }, + { + kind: 'added', + content: 'new', + oldLine: undefined, + newLine: 2, + changedRanges: [{ start: 0, length: 3 }] + }, + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 }, + { + kind: 'added', + content: 'four', + oldLine: undefined, + newLine: 4, + changedRanges: [{ start: 0, length: 4 }] + } + ]) + }) + + it('keeps unchanged trailing lines out of the change counts', () => { + const diff = { before: 'one\ntwo\nthree', after: 'one\nnew\nthree', lang: 'plaintext' } + + expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 }) + expect(toolDiffLines(diff).filter((line) => line.content === 'three')).toEqual([ + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 } + ]) + }) + + it('does not report changes for identical files', () => { + const diff = { before: 'one\ntwo\nthree', after: 'one\ntwo\nthree', lang: 'plaintext' } + + expect(diffLineCounts(diff)).toEqual({ added: 0, removed: 0 }) + expect(toolDiffLines(diff)).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { kind: 'context', content: 'two', oldLine: 2, newLine: 2 }, + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 } + ]) + }) + + it('renders a newly created file', () => { + expect(toolDiffLines({ before: '', after: 'print("hello")', lang: 'python' })).toEqual([ + { + kind: 'added', + content: 'print("hello")', + oldLine: undefined, + newLine: 1, + changedRanges: [{ start: 0, length: 14 }] + } + ]) + }) + + it('renders a deleted file', () => { + expect(toolDiffLines({ before: 'print("goodbye")', after: '', lang: 'python' })).toEqual([ + { + kind: 'removed', + content: 'print("goodbye")', + oldLine: 1, + newLine: undefined, + changedRanges: [{ start: 0, length: 16 }] + } + ]) + }) + + it('uses Monaco line alignment for reordered lines', () => { + expect( + toolDiffLines({ + before: 'const a = 1\nconst b = 2', + after: 'const b = 2\nconst a = 1', + lang: 'typescript' + }) + ).toEqual([ + { + kind: 'added', + content: 'const b = 2', + newLine: 1, + changedRanges: [{ start: 0, length: 11, extendsToEnd: true }] + }, + { kind: 'context', content: 'const a = 1', oldLine: 1, newLine: 2 }, + { + kind: 'removed', + content: 'const b = 2', + oldLine: 2, + changedRanges: [{ start: 0, length: 11 }] + } + ]) + }) + + it('avoids expensive alignment while arguments stream', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + + expect(toolDiffLines({ before, after, lang: 'plaintext' }, true)).toHaveLength(400) + expect(diffLineCounts({ before, after, lang: 'plaintext' }, true)).toEqual({ + added: 1_000, + removed: 1_000 + }) + }) + + it('diffs a small edit in a large file', () => { + const beforeLines = Array.from({ length: 600 }, (_, index) => `line ${index}`) + const afterLines = beforeLines.map((line, index) => (index === 400 ? 'changed' : line)) + const diff = { before: beforeLines.join('\n'), after: afterLines.join('\n'), lang: 'plaintext' } + const lines = toolDiffLines(diff) + + expect(lines.filter((line) => line.kind !== 'context')).toMatchObject([ + { kind: 'removed', content: 'line 400', oldLine: 401 }, + { kind: 'added', content: 'changed', newLine: 401 } + ]) + expect(lines[399]).toEqual({ kind: 'context', content: 'line 399', oldLine: 400, newLine: 400 }) + expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 }) + }) + + it('reports a large rewrite as a whole-file change', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + + expect(diffLineCounts({ before, after, lang: 'plaintext' })).toEqual({ + added: 1_000, + removed: 1_000 + }) + }) + + it('keeps an empty streamed replacement paired with its own edit', () => { + const parameters = + '{"diffs":[{"old_string":"a","new_string":""},{"old_string":"b","new_string":"c"}]}' + + expect(toolCodeDiff(message({ toolName: 'edit_code', parameters }))).toEqual({ + before: 'a\nb', + after: '\nc', + lang: 'plaintext' + }) + }) + + it('recognizes calls whose arguments are a whole file', () => { + const call = ( + toolName: string, + parameters: unknown, + codeDiff?: ToolDisplayMessage['codeDiff'] + ) => argumentsCarryWholeFile(message({ toolName, parameters, codeDiff })) + + expect(call('write_script', { path: 'f/a', content: '' })).toBe(true) + expect(call('edit_code', '{"code":"x')).toBe(true) + expect( + call('edit_code', { code: 'x' }, { before: 'old', after: 'x', lang: 'typescript' }) + ).toBe(true) + expect(call('edit_code', { diffs: [{ old_string: 'a', new_string: 'b' }] })).toBe(false) + expect(call('edit_script', { old_string: 'a', new_string: 'b' })).toBe(false) + }) + + it('shows each row of a small edit once', () => { + const lines = toolDiffLines({ before: 'a\nb\nc\n', after: 'a\nB\nc\n', lang: 'plaintext' }) + + expect(visibleToolDiffRows(lines, new Map())).toEqual(lines) + expect(lines.map((line) => line.kind)).toEqual(['context', 'removed', 'added', 'context']) + }) + + it('bounds the rows of a large rewrite', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + const lines = toolDiffLines({ before, after, lang: 'plaintext' }) + const rows = visibleToolDiffRows(lines, new Map()) + + expect(rows).toHaveLength(802) + expect(rows[400]).toEqual({ kind: 'omitted', key: 'removed:0', count: 600 }) + expect(rows[401]).toMatchObject({ kind: 'added', newLine: 1 }) + expect(rows[801]).toEqual({ kind: 'omitted', key: 'added:1000', count: 600 }) + + // One click on a run reveals 400 more rows; the overall cap still holds until `end` is clicked. + const expanded = visibleToolDiffRows(lines, new Map([['removed:0', 1]])) + expect(expanded).toHaveLength(1_001) + expect(expanded[800]).toEqual({ kind: 'omitted', key: 'removed:0', count: 200 }) + expect(expanded[1_000]).toEqual({ kind: 'omitted', key: 'end', count: 801 }) + expect( + visibleToolDiffRows( + lines, + new Map([ + ['removed:0', 1], + ['end', 1] + ]) + ) + ).toHaveLength(1_202) + }) + + it('highlights each line within the scope of the whole source', () => { + const typescript = toolCodeDiffLanguage('typescript') + hljs.registerLanguage(typescript.name, typescript.register) + const code = 'const s = `a\nb`\nconst n = 1\n' + + const lines = highlightedSourceLines(code, typescript.name) + expect(lines).toHaveLength(3) + expect(lines[0]).toBe( + 'const s = `a' + ) + expect(lines[1]).toMatch(/^b`<\/span>/) + expect(highlightedSourceLines(code, typescript.name, 2)).toEqual(lines.slice(0, 2)) + expect(highlightedSourceLines('a\nb', typescript.name)).toEqual(['a', 'b']) + expect(highlightedSourceLines('a\nb\n', typescript.name)).toEqual(['a', 'b']) + expect(highlightedSourceLines('', typescript.name)).toEqual([]) + + const large = Array.from({ length: 2_001 }, () => 'const a = 1 < 2').join('\n') + '\n' + const plain = highlightedSourceLines(large, typescript.name) + expect(plain).toHaveLength(2_001) + expect(plain[0]).toBe('const a = 1 < 2') + + const minified = highlightedSourceLines('x'.repeat(100_001), typescript.name) + expect(minified).toEqual(['x'.repeat(10_000) + '…']) + }) + + it('has a highlighter for every supported editor language', () => { + expect(TOOL_CODE_DIFF_LANGUAGES).toEqual( + expect.arrayContaining([ + 'typescript', + 'javascript', + 'python', + 'json', + 'yaml', + 'sql', + 'shell', + 'powershell', + 'php', + 'rust', + 'graphql', + 'csharp', + 'nu', + 'java', + 'r', + 'go', + 'ruby', + 'plaintext' + ]) + ) + for (const language of TOOL_CODE_DIFF_LANGUAGES) { + const highlighter = toolCodeDiffLanguage(language) + hljs.registerLanguage(highlighter.name, highlighter.register) + expect(() => + hljs.highlight('const changed = true', { language: highlighter.name }) + ).not.toThrow() + } + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts new file mode 100644 index 0000000000..2e5d8bf13c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts @@ -0,0 +1,381 @@ +// This is Monaco's advanced diff engine; it supplies the same multi-line inner ranges. +import { DefaultLinesDiffComputer } from '@codingame/monaco-vscode-api/vscode/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer' +import type { ToolCodeDiff, ToolDisplayMessage } from './shared' + +// Builds a diff from a call's own arguments, for the moments the saved `codeDiff` does not +// exist: while the arguments stream, when the call failed, and for transcripts saved before +// calls recorded one. Argument diffs have no language or surrounding lines. +const ARGS_DIFF_BY_TOOL: Record ToolCodeDiff | undefined> = { + edit_script: (params) => argumentDiff(streamingEditArguments(params)), + write_script: (params) => fullContentArgumentDiff(params, 'content'), + edit_code: (params) => { + if ( + params && + typeof params === 'object' && + Array.isArray((params as { diffs?: unknown }).diffs) + ) { + const diffs = (params as { diffs: unknown[] }).diffs + return argumentDiffs(diffs.map(streamingEditArguments)) + } + if (typeof params === 'string') { + return ( + argumentDiffs(streamingEditArgumentsArray(params)) ?? + fullContentArgumentDiff(params, 'code') + ) + } + return argumentDiff(streamingEditArguments(params)) ?? fullContentArgumentDiff(params, 'code') + } +} + +function fullContentArgumentDiff(params: unknown, key: string): ToolCodeDiff | undefined { + const content = + params && typeof params === 'object' + ? (params as Record)[key] + : typeof params === 'string' + ? partialJsonString(params, key) + : undefined + return typeof content === 'string' ? { before: '', after: content, lang: 'plaintext' } : undefined +} + +function argumentDiff( + edit: { old_string?: string; new_string?: string } | undefined +): ToolCodeDiff | undefined { + return typeof edit?.old_string === 'string' || typeof edit?.new_string === 'string' + ? { + before: typeof edit.old_string === 'string' ? edit.old_string : '', + after: typeof edit.new_string === 'string' ? edit.new_string : '', + lang: 'plaintext' + } + : undefined +} + +function argumentDiffs( + edits: ({ old_string?: string; new_string?: string } | undefined)[] +): ToolCodeDiff | undefined { + const diffs = edits.map(argumentDiff).filter((diff): diff is ToolCodeDiff => diff !== undefined) + if (diffs.length === 0) return undefined + return { + before: diffs.map((diff) => diff.before).join('\n'), + after: diffs.map((diff) => diff.after).join('\n'), + lang: 'plaintext' + } +} + +function streamingEditArguments( + params: unknown +): { old_string?: string; new_string?: string } | undefined { + if (params && typeof params === 'object') + return params as { old_string?: string; new_string?: string } + if (typeof params !== 'string') return undefined + + return { + old_string: partialJsonString(params, 'old_string'), + new_string: partialJsonString(params, 'new_string') + } +} + +function streamingEditArgumentsArray( + partialJson: string +): { old_string?: string; new_string?: string }[] { + const oldStrings = partialJsonStrings(partialJson, 'old_string') + const newStrings = partialJsonStrings(partialJson, 'new_string') + return Array.from({ length: Math.max(oldStrings.length, newStrings.length) }, (_, index) => ({ + old_string: oldStrings[index], + new_string: newStrings[index] + })) +} + +function partialJsonString(partialJson: string, key: string): string | undefined { + return partialJsonStrings(partialJson, key)[0] +} + +function partialJsonStrings(partialJson: string, key: string): string[] { + const matches = partialJson.matchAll(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'g')) + const result: string[] = [] + // An empty string is a real value (a deletion): skipping it would pair the next edit's + // `new_string` with this edit's `old_string`. + for (const match of matches) { + try { + result.push(JSON.parse(`"${match[1]}"`)) + } catch { + continue + } + } + return result +} + +export function hasToolCodeDiff(toolName: string | undefined): boolean { + return toolName !== undefined && Object.hasOwn(ARGS_DIFF_BY_TOOL, toolName) +} + +export function toolCodeDiff(message: ToolDisplayMessage): ToolCodeDiff | undefined { + if (message.codeDiff) return message.codeDiff + if (!message.toolName) return undefined + return Object.hasOwn(ARGS_DIFF_BY_TOOL, message.toolName) + ? ARGS_DIFF_BY_TOOL[message.toolName](message.parameters) + : undefined +} + +// Decided by tool and argument shape, never by the saved `codeDiff`: an overwrite's saved diff +// has a before side, and clearing a script leaves both argument sides empty, yet both calls sent +// a whole file. +export function argumentsCarryWholeFile(message: ToolDisplayMessage): boolean { + if (message.toolName === 'write_script') return true + if (message.toolName !== 'edit_code') return false + const params = message.parameters + if (typeof params === 'string') return /"code"\s*:/.test(params) + return Boolean(params && typeof params === 'object' && 'code' in params) +} + +export function diffLineCounts( + diff: ToolCodeDiff, + streaming = false +): { added: number; removed: number } { + if (streaming) { + return { added: lines(diff.after).length, removed: lines(diff.before).length } + } + + return toolDiffLineCounts(toolDiffLines(diff)) +} + +export function toolDiffLineCounts(lines: ToolDiffLine[]): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const line of lines) { + if (line.kind === 'added') added++ + else if (line.kind === 'removed') removed++ + } + return { added, removed } +} + +export type ToolDiffLine = { + kind: 'context' | 'added' | 'removed' + content: string + oldLine?: number + newLine?: number + changedRanges?: CharacterRange[] +} + +export type CharacterRange = { start: number; length: number; extendsToEnd?: boolean } + +const STREAMING_PREVIEW_LINES = 200 + +function lines(value: string): string[] { + const result = value.split('\n') + if (result.at(-1) === '') result.pop() + return result +} + +function hasFinalNewline(value: string): boolean { + return value.endsWith('\n') +} + +export function toolDiffLines(diff: ToolCodeDiff, streaming = false): ToolDiffLine[] { + const before = lines(diff.before) + const after = lines(diff.after) + if (streaming) return streamingDiffLines(before, after) + + const result: ToolDiffLine[] = [] + const monacoBefore = monacoLines(diff.before) + const monacoAfter = monacoLines(diff.after) + // Runs on the UI thread. Cost follows the amount changed, not file size: only a large + // rewrite reaches the cap, and Monaco then reports the whole file as one change. + const changes = new DefaultLinesDiffComputer().computeDiff(monacoBefore, monacoAfter, { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 200, + computeMoves: false, + extendToSubwords: false + }).changes + const removedRanges = new Map() + const addedRanges = new Map() + for (const change of changes) { + for (const innerChange of change.innerChanges ?? []) { + addCharacterRanges(removedRanges, monacoBefore, innerChange.originalRange) + addCharacterRanges(addedRanges, monacoAfter, innerChange.modifiedRange) + } + } + + let oldIndex = 0 + let newIndex = 0 + for (const change of changes) { + const oldStart = change.original.startLineNumber - 1 + const newStart = change.modified.startLineNumber - 1 + appendContextLines( + result, + before, + after, + oldIndex, + newIndex, + Math.min(oldStart - oldIndex, newStart - newIndex) + ) + appendChangedLines( + result, + 'removed', + before, + oldStart, + change.original.endLineNumberExclusive - 1, + removedRanges + ) + appendChangedLines( + result, + 'added', + after, + newStart, + change.modified.endLineNumberExclusive - 1, + addedRanges + ) + oldIndex = Math.min(change.original.endLineNumberExclusive - 1, before.length) + newIndex = Math.min(change.modified.endLineNumberExclusive - 1, after.length) + } + const trailingContext = Math.max(0, Math.min(before.length - oldIndex, after.length - newIndex)) + appendContextLines(result, before, after, oldIndex, newIndex, trailingContext) + oldIndex += trailingContext + newIndex += trailingContext + appendChangedLines(result, 'removed', before, oldIndex, before.length, removedRanges) + appendChangedLines(result, 'added', after, newIndex, after.length, addedRanges) + if ( + diff.before !== '' && + diff.after !== '' && + hasFinalNewline(diff.before) !== hasFinalNewline(diff.after) + ) { + result.push( + hasFinalNewline(diff.before) + ? { kind: 'removed', content: '', oldLine: before.length + 1 } + : { kind: 'added', content: '', newLine: after.length + 1 } + ) + } + + return result +} + +export type VisibleToolDiffRow = + | ToolDiffLine + | { kind: 'collapsed'; key: string; count: number } + | { kind: 'omitted'; key: string; count: number } + +const CONTEXT_LINES = 3 +// Every visible row is a DOM row with no virtualization, and only context collapses: a whole-file +// rewrite would otherwise render every line of both sides. Two capped runs fit under the overall +// cap, so a rewrite still shows its added side. Each expansion of an omitted row reveals one more +// cap's worth, so no single click renders an unbounded number of rows. +const MAX_CHANGED_RUN_ROWS = 400 +const MAX_VISIBLE_ROWS = 1_000 + +// `expansions` counts the clicks on each collapsed or omitted row, by its key. +export function visibleToolDiffRows( + lines: ToolDiffLine[], + expansions: ReadonlyMap +): VisibleToolDiffRow[] { + const result: VisibleToolDiffRow[] = [] + for (let index = 0; index < lines.length; ) { + const start = index + const kind = lines[start].kind + while (index < lines.length && lines[index].kind === kind) index++ + const count = index - start + + if (kind !== 'context') { + const key = `${kind}:${start}` + const shown = MAX_CHANGED_RUN_ROWS * (1 + (expansions.get(key) ?? 0)) + result.push(...lines.slice(start, Math.min(index, start + shown))) + if (count > shown) result.push({ kind: 'omitted', key, count: count - shown }) + continue + } + + const key = `${lines[start].oldLine}:${lines[start].newLine}:${count}` + if (count <= CONTEXT_LINES * 2 + 1 || expansions.has(key)) { + result.push(...lines.slice(start, index)) + continue + } + result.push(...lines.slice(start, start + CONTEXT_LINES)) + result.push({ kind: 'collapsed', key, count: count - CONTEXT_LINES * 2 }) + result.push(...lines.slice(index - CONTEXT_LINES, index)) + } + + const maxRows = MAX_VISIBLE_ROWS * (1 + (expansions.get('end') ?? 0)) + if (result.length <= maxRows) return result + const hidden = result + .slice(maxRows) + .reduce((total, row) => total + ('count' in row ? row.count : 1), 0) + return [...result.slice(0, maxRows), { kind: 'omitted', key: 'end', count: hidden }] +} + +function streamingDiffLines(before: string[], after: string[]): ToolDiffLine[] { + return [ + ...before + .slice(0, STREAMING_PREVIEW_LINES) + .map((content, index) => ({ kind: 'removed' as const, content, oldLine: index + 1 })), + ...after + .slice(0, STREAMING_PREVIEW_LINES) + .map((content, index) => ({ kind: 'added' as const, content, newLine: index + 1 })) + ] +} + +function monacoLines(value: string): string[] { + const result = lines(value) + return result.length ? result : [''] +} + +function appendContextLines( + result: ToolDiffLine[], + before: string[], + after: string[], + oldStart: number, + newStart: number, + count: number +): void { + for (let index = 0; index < count; index++) { + result.push({ + kind: 'context', + content: before[oldStart + index], + oldLine: oldStart + index + 1, + newLine: newStart + index + 1 + }) + } +} + +function appendChangedLines( + result: ToolDiffLine[], + kind: 'added' | 'removed', + source: string[], + start: number, + end: number, + ranges: Map +): void { + for (let index = start; index < Math.min(end, source.length); index++) { + const changedRanges = ranges.get(index + 1) + result.push({ + kind, + content: source[index], + ...(kind === 'added' ? { newLine: index + 1 } : { oldLine: index + 1 }), + ...(changedRanges?.length ? { changedRanges } : {}) + }) + } +} + +function addCharacterRanges( + ranges: Map, + lines: string[], + range: { + startLineNumber: number + startColumn: number + endLineNumber: number + endColumn: number + } +): void { + for ( + let lineNumber = range.startLineNumber; + lineNumber <= Math.min(range.endLineNumber, lines.length); + lineNumber++ + ) { + const content = lines[lineNumber - 1] + const start = lineNumber === range.startLineNumber ? range.startColumn - 1 : 0 + const extendsToEnd = lineNumber < range.endLineNumber + const end = extendsToEnd ? content.length : range.endColumn - 1 + if (end > start) { + ranges.set(lineNumber, [ + ...(ranges.get(lineNumber) ?? []), + { start, length: end - start, ...(extendsToEnd ? { extendsToEnd } : {}) } + ]) + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts new file mode 100644 index 0000000000..b63d002e9e --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts @@ -0,0 +1,57 @@ +import hljs from 'highlight.js/lib/core' +import { escapeHtml } from '$lib/utils' + +// highlight.js runs on the UI thread at roughly 15 ms per 1,000 lines (50 KB), and must see the +// whole source for multi-line strings and comments to scope correctly. Larger sides, including +// minified one-line sources, render as plain text with each line cut at MAX_PLAIN_LINE_CHARS. +const MAX_HIGHLIGHTED_LINES = 2_000 +const MAX_HIGHLIGHTED_CHARS = 100_000 +const MAX_PLAIN_LINE_CHARS = 10_000 + +// Highlights the whole source before splitting it, so a line inside a multi-line string or +// comment keeps that scope. Only the first `lineLimit` lines are highlighted: a streamed source +// grows on every delta while the preview shows a bounded prefix. +export function highlightedSourceLines( + code: string, + languageName: string, + lineLimit = Infinity +): string[] { + if (code === '' || lineLimit <= 0) return [] + const sourceLines = code.split('\n') + const truncated = sourceLines.length > lineLimit + const shownLines = truncated ? sourceLines.slice(0, lineLimit) : sourceLines + const source = truncated ? shownLines.join('\n') : code + if (shownLines.length > MAX_HIGHLIGHTED_LINES || source.length > MAX_HIGHLIGHTED_CHARS) { + const plain = shownLines.map((line) => + escapeHtml( + line.length > MAX_PLAIN_LINE_CHARS ? line.slice(0, MAX_PLAIN_LINE_CHARS) + '…' : line + ) + ) + if (!truncated && code.endsWith('\n')) plain.pop() + return plain + } + const highlighted = hljs.highlight(source, { language: languageName }).value + return splitHighlightedLines(highlighted, !truncated && code.endsWith('\n')) +} + +// Returns one entry per source line, closing the spans still open at each line end and +// reopening them on the next line so every entry is balanced HTML. +export function splitHighlightedLines(highlighted: string, hasFinalNewline: boolean): string[] { + const result: string[] = [] + const openTags: string[] = [] + let line = '' + + for (const token of highlighted.split(/(<[^>]+>|\n)/)) { + if (token === '\n') { + result.push(line + ''.repeat(openTags.length)) + line = openTags.join('') + } else { + line += token + if (token.startsWith('') openTags.pop() + } + } + + if (!hasFinalNewline) result.push(line) + return result +} diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts new file mode 100644 index 0000000000..ddf2464136 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts @@ -0,0 +1,43 @@ +import { csharp, go, java, json, plaintext, type LanguageType } from 'svelte-highlight/languages' +import graphql from 'svelte-highlight/languages/graphql' +import javascript from 'svelte-highlight/languages/javascript' +import php from 'svelte-highlight/languages/php' +import python from 'svelte-highlight/languages/python' +import powershell from 'svelte-highlight/languages/powershell' +import r from 'svelte-highlight/languages/r' +import ruby from 'svelte-highlight/languages/ruby' +import rust from 'svelte-highlight/languages/rust' +import shell from 'svelte-highlight/languages/shell' +import sql from 'svelte-highlight/languages/sql' +import typescript from 'svelte-highlight/languages/typescript' +import yaml from 'svelte-highlight/languages/yaml' + +// Keyed by the editor language `scriptLangToEditorLang` returns, not the script language. +// for related places search: ADD_NEW_LANG +const LANGUAGE_BY_MONACO: Record> = { + typescript, + javascript, + python, + json, + yaml, + sql, + shell, + powershell, + php, + rust, + graphql, + csharp, + nu: python, + java, + r, + go, + ruby, + text: plaintext, + plaintext +} + +export const TOOL_CODE_DIFF_LANGUAGES = Object.keys(LANGUAGE_BY_MONACO) + +export function toolCodeDiffLanguage(language: string): LanguageType { + return LANGUAGE_BY_MONACO[language] ?? plaintext +} diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index 6ef18b7212..077090aba2 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -3,7 +3,7 @@ import type { ChatSendRequestOptions, ChatViewHost } from '$lib/components/copilot/chat/chatViewHost' -import type { DisplayMessage } from '$lib/components/copilot/chat/shared' +import type { DisplayMessage, WebSearchResult } from '$lib/components/copilot/chat/shared' import type { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte' import { isPlanCardTool } from '$lib/components/copilot/chat/planMode' import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte' @@ -125,6 +125,33 @@ function toolErrorText(result: unknown): string | undefined { return undefined } +/** + * The provider-side search's row as the web search result shape, or undefined for any other + * row. The worker words it like a tool named `websearch`, which an agent tool may also be + * called; what tells them apart is that every agent tool's row stores its arguments, and the + * search stores none, only the citations it produced. + */ +function builtInWebSearch(message: ChatMessage): WebSearchResult | undefined { + const tool = message.tool + if (tool?.name !== 'websearch' || tool.arguments !== undefined || message.pending) { + return undefined + } + const citations = parseToolPayload(tool.result) + if (citations === undefined) return { sources: [] } + if ( + !Array.isArray(citations) || + !citations.every((c) => c && typeof c === 'object' && typeof c.url === 'string') + ) { + return undefined + } + return { + sources: citations.map((c: { url: string; title?: unknown }) => ({ + url: c.url, + title: typeof c.title === 'string' ? c.title : undefined + })) + } +} + /** The name the worker gives the structured-output tool: suffixed when an agent tool already has it. */ const STRUCTURED_OUTPUT_CALL = /^structured_output(_\d+)?$/ @@ -168,6 +195,25 @@ export function toDisplayMessages( ] } case 'tool': { + const search = builtInWebSearch(message) + if (search) { + const found = search.sources.length > 0 + // `autoCollapseDetails: false` opens it on its sources, as the session chat + // shows a search. A tool that returns the same shape stays collapsed like + // every other tool row, however many times the agent called it. + return [ + { + role: 'tool', + tool_call_id: message.id, + content: 'Searched the web', + toolName: 'web_search', + webSearchSources: found ? search.sources : undefined, + showDetails: found, + autoCollapseDetails: false, + jobId: message.jobId + } + ] + } // The model's call and what the tool sent back, as the stream carried them or the // worker stored them on the row. A row stored without them shows its name and job. const toolName = message.tool?.name diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts index 53fd7b6ab9..a0ae4b8092 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts @@ -125,6 +125,42 @@ describe('toDisplayMessages', () => { }) }) + // The worker words the provider-side search like a tool named `websearch`, which an agent + // tool may also be called. The search stores no arguments, and an agent tool always does. + it('renders the provider-side search as a web search card, not an agent tool of that name', () => { + const citations = JSON.stringify([ + { start_index: 0, end_index: 4, url: 'https://postgresql.org/docs', title: 'Release notes' } + ]) + const [search, agentTool, notCitations] = toDisplayMessages([ + message({ + role: 'tool', + content: 'Used websearch tool', + tool: { name: 'websearch', status: 'success', result: citations } + }), + message({ + role: 'tool', + content: 'Used websearch tool', + tool: { name: 'websearch', status: 'success', arguments: '{}', result: citations } + }), + // The safety valve of a name-based rule: a row that is not the provider's search + // keeps its result, whatever the tool is called. + message({ + role: 'tool', + content: 'Used websearch tool', + tool: { name: 'websearch', status: 'success', result: '{"hits":3}' } + }) + ]) + expect(search).toMatchObject({ + content: 'Searched the web', + webSearchSources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }], + showDetails: true, + autoCollapseDetails: false + }) + expect(agentTool).toMatchObject({ content: 'Used websearch tool', toolName: 'websearch' }) + expect(agentTool).not.toHaveProperty('webSearchSources') + expect(notCitations).toMatchObject({ toolName: 'websearch', result: { hits: 3 } }) + }) + it('shows a call the turn finished without as an error, not as still running', () => { const display = toDisplayMessages([ message({ role: 'user', content: 'hi' }), diff --git a/frontend/src/routes/test_dev/diff-comparison/+page.svelte b/frontend/src/routes/test_dev/diff-comparison/+page.svelte new file mode 100644 index 0000000000..c473dbcb64 --- /dev/null +++ b/frontend/src/routes/test_dev/diff-comparison/+page.svelte @@ -0,0 +1,139 @@ + + + + Diff comparison + + +
+
+ {#each cases as item} + + {/each} +
+ +
+
+

Monaco

+
+ {#key activeCase.id} + + {/key} +
+
+ +
+

Native

+
+ {#key activeCase.id} + + {/key} +
+
+
+