From 40476e7be957230462d30543900b7166ed5e9d10 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Wed, 23 Sep 2026 06:59:50 -0400 Subject: [PATCH 1/5] ci: skip the AI reviews and the Discord notice on Dependabot PRs (#11307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: skip the AI reviews and the Discord notice on Dependabot PRs They already do not review them, they just fail while doing so. The Claude action rejects a bot actor outright ("Workflow initiated by non-human actor"), and the Codex and Pi jobs find no API key because GitHub gives Dependabot- triggered runs a separate secret scope from Actions. Verified on #11302: zero reviews posted, the only comment is a Cloudflare deployment notice, while codex-review and pi-review both reported success. So every Dependabot PR carried two permanently red checks that meant nothing, which is the worst kind of signal — it buries a Dependabot PR that genuinely is broken, and a green tick that means "skipped" reads exactly like one that means "looked and approved". Skipping states it honestly. The workflow_call branch is untouched, so a review can still be requested on a specific bot PR when the diff deserves one, which is worth doing for a grouped security update that swaps a cipher or drops a parser rather than just moving a version. The Discord notice is excluded for the same reason plus its own: nobody wants a forum thread per lockfile bump. Not fixed here, deliberately: making these actually review bot PRs would need the org's review credentials copied into the Dependabot secret scope, which is a wider grant than this is worth given the PRs in question are lockfile diffs. Co-Authored-By: Claude Opus 5 (1M context) * ci: gate the close-time Discord job too, and trim the comments Both reviewers caught the same gap: merge_success_emoji runs on every `closed` event with no exclusion, and the reusable workflow it calls exits 1 when it cannot find a thread. Since open_thread no longer creates one for Dependabot, the failure would have moved from open-time to merge-time rather than going away. Gated to match. The comments are cut from eight lines to two per file. AGENTS.md:205 asks for constraints in <=4 lines, stated once, describing the code as it is — mine narrated the drafting history and argued the change to a reviewer, both of which belong in the PR description. Also drops "bot-authored", which overstated a condition that only covers dependabot[bot]. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/codex-pr-review.yml | 5 ++++- .github/workflows/discord-notification.yml | 6 ++++-- .github/workflows/pi-pr-review.yml | 5 ++++- .github/workflows/pr-ready-review.yml | 5 ++++- 4 files changed, 16 insertions(+), 5 deletions(-) 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 From fa0fc242691ed3997c14e821b6030166223c0da0 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 23 Sep 2026 14:33:12 +0200 Subject: [PATCH 2/5] feat: render a tool's web search result as the session web search card (#11313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: render a tool's web search result as the session web search card Any tool whose result is exactly `{ sources: [{ url, title? }], query? }` now renders as the card the AI session shows for a provider-side web search, in the agent step trace and in the flow chat. The provider-side search itself gets the same card on both surfaces: its citations, which the worker records on the assistant turn that follows, move onto the search row. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the trace tool label off the lazily fetched job The web search card's label read the tool's job, which is only fetched once the row is expanded, so the query suffix appeared only after the row had been opened. The shape of the result decides the label; the job's failure still decides whether the sources or the error show. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep unrenderable sources and tool hostnames out of the search card A result whose urls the card cannot render — relative, or javascript: — matched the shape and replaced the tool's own output with an empty list, so the predicate now requires what the renderer accepts. A tool's sources can name internal services, so they no longer resolve a favicon through Google's service; provider-side search results still do. An absent optional reaches JSON as null from a Python tool, which now reads as absent rather than rejecting the whole result. Co-Authored-By: Claude Opus 5 (1M context) * fix: name the host when a source carries a blank title A source whose title is an empty string rendered as a link with no text at all, the empty title also suppressing the hostname beside it. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../lib/components/AgentResultDisplay.svelte | 11 +-- frontend/src/lib/components/AgentTrace.svelte | 66 ++++++++++------- .../src/lib/components/agentTrace.test.ts | 29 ++++---- frontend/src/lib/components/agentTrace.ts | 36 +++++----- .../copilot/chat/ToolExecutionDisplay.svelte | 20 ++++-- .../chat/WebSearchSourcesDisplay.svelte | 35 +++++---- .../components/copilot/chat/shared.test.ts | 37 +++++++++- .../src/lib/components/copilot/chat/shared.ts | 71 +++++++++++++++++++ .../conversations/flowChatViewHost.svelte.ts | 48 ++++++++++++- .../conversations/flowChatViewHost.test.ts | 36 ++++++++++ 10 files changed, 299 insertions(+), 90 deletions(-) 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/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 5405a6ea25..8d945f94d1 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' @@ -140,6 +140,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} @@ -281,7 +293,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 +355,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/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..772284865d 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -610,6 +610,77 @@ export type WebSearchSource = { title?: 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 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' }), From 1de54eeea4c4820e7291d9648a44ef3c86979c5a Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 23 Sep 2026 15:44:40 +0200 Subject: [PATCH 3/5] feat(ai-chat): show native script edit diffs (#11278) * feat(ai-chat): show native script edit diffs * fix: render streamed edit diffs * fix: render in-editor script edit diffs * style: soften diff context separators * fix: render full script update diffs * fix: support empty script diffs * fix: bound streamed tool diffs * fix: preserve trailing diff context * fix: defer collapsed tool diffs * fix: retain full-code streaming previews * fix: preserve created script diffs * fix: disambiguate collapsed diff keys * fix: render final newline diffs * fix: preserve multiline diff highlighting * fix: highlight diff sides as complete sources * fix: bound completed tool diffs * fix: diff large completed tool edits Co-Authored-By: Claude Opus 5.5 (1M context) * fix: bound tool diff rendering Co-Authored-By: Claude Opus 5.5 (1M context) * fix: bound tool diff highlighting and expand omitted rows Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reveal omitted diff rows in chunks and bound long-line highlighting Co-Authored-By: Claude Opus 5.5 (1M context) * fix: collapse written scripts, skip empty diff bodies and fit line numbers Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep whole-file tool diffs collapsed while running Co-Authored-By: Claude Opus 5.5 (1M context) * fix: classify whole-file tool calls by argument shape Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reopen failed diff cards and keep empty streamed replacements Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../copilot/chat/ToolCodeDiffView.svelte | 170 ++++++++ .../copilot/chat/ToolDiffCard.svelte | 103 +++++ .../copilot/chat/ToolExecutionDisplay.svelte | 5 + .../copilot/chat/global/core.test.ts | 38 +- .../components/copilot/chat/global/core.ts | 35 +- .../copilot/chat/script/core.test.ts | 50 +++ .../components/copilot/chat/script/core.ts | 15 +- .../src/lib/components/copilot/chat/shared.ts | 10 + .../copilot/chat/toolCodeDiff.test.ts | 411 ++++++++++++++++++ .../components/copilot/chat/toolCodeDiff.ts | 381 ++++++++++++++++ .../copilot/chat/toolCodeDiffHighlight.ts | 57 +++ .../copilot/chat/toolCodeDiffLanguage.ts | 43 ++ .../test_dev/diff-comparison/+page.svelte | 139 ++++++ 13 files changed, 1443 insertions(+), 14 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte create mode 100644 frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte create mode 100644 frontend/src/lib/components/copilot/chat/script/core.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolCodeDiff.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts create mode 100644 frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts create mode 100644 frontend/src/routes/test_dev/diff-comparison/+page.svelte 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 8d945f94d1..d5fee5a4d9 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -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. @@ -170,6 +173,8 @@
{:else if isRunCard} +{:else if isDiffCard} + {:else if planState} 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.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 772284865d..83a51b527c 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -610,6 +610,13 @@ 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[] @@ -725,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/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} +
+
+
+
From 7593597617110fbd276f3f32ca2be7064547a080 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 23 Sep 2026 15:50:32 +0200 Subject: [PATCH 4/5] fix: validate app and trigger paths, refuse traversal in workspace export (#11311) * fix: enforce proper_id paths on apps and triggers, refuse traversal in export Co-Authored-By: Claude Opus 5.5 (1M context) * fix: skip proper_id on tables already holding non-conforming paths Co-Authored-By: Claude Opus 5.5 (1M context) * test: pin archive entry path traversal guard Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse windows-normalized traversal in export, check raw app path early Co-Authored-By: Claude Opus 5.5 (1M context) * fix: only treat a colon in the first archive segment as a drive prefix Co-Authored-By: Claude Opus 5.5 (1M context) * test: accept a colon past the first archive segment Co-Authored-By: Claude Opus 5.5 (1M context) * revert: drop proper_id migration, keep path validation in the API Co-Authored-By: Claude Opus 5.5 (1M context) * fix: validate paths in bulk http trigger creation Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-api/src/apps.rs | 7 ++- backend/windmill-api/src/workspaces_export.rs | 60 ++++++++++++++++++- backend/windmill-common/src/utils.rs | 3 + backend/windmill-trigger-http/src/handler.rs | 2 + backend/windmill-trigger/src/handler.rs | 6 +- 5 files changed, 75 insertions(+), 3 deletions(-) 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()?; From ceb17223dcb5f66532017036916e91a5323279fb Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 23 Sep 2026 16:36:23 +0200 Subject: [PATCH 5/5] fix: bill from a single service account telemetry count (#11301) * [ee] fix: bill from a single service account telemetry count Co-Authored-By: Claude Opus 5 * [ee] fix: count service accounts held in forks Co-Authored-By: Claude Opus 5 * chore: update ee-repo-ref to ed5a367b4def5280c3a5e1090e7abfa099a01042 This commit updates the EE repository reference after PR #824 was merged in windmill-ee-private. Previous ee-repo-ref: bc1fe61364c2a731ec26186eb04596254886a74c New ee-repo-ref: ed5a367b4def5280c3a5e1090e7abfa099a01042 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 Co-authored-by: windmill-internal-app[bot] --- ...5ab0199084b35664cbb0a586917bfa0da075.json} | 4 ++-- ...6aed1162fe85cb31c0414bc7e9b93a0684b43.json | 20 ------------------- backend/ee-repo-ref.txt | 2 +- 3 files changed, 3 insertions(+), 23 deletions(-) rename backend/.sqlx/{query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json => query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json} (58%) delete mode 100644 backend/.sqlx/query-e60b837837f9ca9ec9587c6f7b66aed1162fe85cb31c0414bc7e9b93a0684b43.json 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