From 471147135b419d7c82544bec740ee8c909d99529 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 18 Jun 2026 16:44:50 +0200 Subject: [PATCH 1/4] oauth: complete Coupa managed client-credentials (instance mapping + default scopes) (#9651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * oauth: map Coupa instance to instance_url resource arg Coupa's managed client-credentials connect collects an instance name to host-pin the token URL but had no resource_mapping, so the created resource's instance_url (the API base URL the hub scripts build on) stayed empty. Add the mapping, mirroring ServiceNow, so the entered instance fills it automatically. Co-Authored-By: Claude Fable 5 * oauth: default Coupa client-credentials scopes (cc_scopes) Prefill the connect dialog's scope field with the core.* scopes the Coupa hub scripts exercise — read+write for suppliers/purchase_orders/requisitions/invoices, read-only for contracts/expenses (the shipped scripts only read those). Scope names verified against the Coupa scope docs and corroborated in production code. The user can trim them to what their OIDC client is granted. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- backend/oauth_connect.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 0b9a19b873..421136fc77 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -133,12 +133,27 @@ }, "coupa": { "grant_types": ["client_credentials"], + "cc_scopes": [ + "core.supplier.read", + "core.supplier.write", + "core.purchase_order.read", + "core.purchase_order.write", + "core.requisition.read", + "core.requisition.write", + "core.invoice.read", + "core.invoice.write", + "core.contract.read", + "core.expense.read" + ], "connect_config_template": { "display_name": "Coupa", "label": "Coupa instance", "placeholder": "your-instance", "token_url": "https://{instance}.coupahost.com/oauth2/token", - "strip_suffix": ".coupahost.com" + "strip_suffix": ".coupahost.com", + "resource_mapping": { + "instance_url": "https://{instance}.coupahost.com" + } } }, "sage_intacct": { From fdd82f0c48f29805cd9e219649f27fba45c7fd92 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 18 Jun 2026 16:45:10 +0200 Subject: [PATCH 2/4] fix: gate agent-worker global setting reads with a blocklist (#9623) * fix: restrict agent-worker global setting reads to an allowlist Add AGENT_WORKER_READABLE_SETTINGS allowlist of the operational settings agent workers load over HTTP, with a helper used by the agent endpoint to reject any other key. Bump ee-repo-ref for the companion EE handler change. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: update ee-repo-ref to 3fab9f01ecce3dad0aa9b9c544d41f1e88bc81dd This commit updates the EE repository reference after PR #615 was merged in windmill-ee-private. Previous ee-repo-ref: 8a657066fda1c5ffe225588bce6c349cffd81e98 New ee-repo-ref: 3fab9f01ecce3dad0aa9b9c544d41f1e88bc81dd Automated by sync-ee-ref workflow. * fix: make agent-worker setting gate a blocklist instead of allowlist Switch is_setting_readable_by_agent_worker to deny-by-exception: serve every global setting to agent workers except AGENT_WORKER_BLOCKED_SETTINGS (the instance secrets). Update tests and bump ee-repo-ref for the companion comment change. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: remind to blocklist new secret settings for agent workers Co-Authored-By: Claude Opus 4.8 (1M context) * chore: update ee-repo-ref to 9e4dadafb44ba953a7d2af2be12b92be98d86b66 This commit updates the EE repository reference after PR #618 was merged in windmill-ee-private. Previous ee-repo-ref: 8e32afb69ffc4d5f080c0c4bc6b023d57d0f39ae New ee-repo-ref: 9e4dadafb44ba953a7d2af2be12b92be98d86b66 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../windmill-common/src/global_settings.rs | 108 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e8927cee6d..0ed0d2d025 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -136f4634aca61e74ccb045372358a1e3f6b23e75 +9e4dadafb44ba953a7d2af2be12b92be98d86b66 diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 732b9cdc30..bed78113b5 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -1,3 +1,8 @@ +// Adding a global setting? Decide whether agent workers may read it. Agent +// workers (remote workers connected over HTTP) fetch settings through an +// endpoint that is deny-by-exception: every key is served except those in +// AGENT_WORKER_BLOCKED_SETTINGS (defined below). If a new setting holds an +// instance secret the server should keep to itself, add its key there. pub const CUSTOM_TAGS_SETTING: &str = "custom_tags"; pub const DEFAULT_TAGS_PER_WORKSPACE_SETTING: &str = "default_tags_per_workspace"; pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces"; @@ -104,6 +109,53 @@ pub const WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING: &str = "workspace_fairness_max pub const WORKSPACE_FAIRNESS_DURATION_SECS_SETTING: &str = "workspace_fairness_duration_secs"; pub const WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING: &str = "workspace_fairness_min_total_jobs"; +/// Global settings an agent worker (a remote worker connected over HTTP instead +/// of to the database) must NEVER read through +/// `GET /api/agent_workers/get_global_setting/{key}`. Every other key is served. +/// +/// SECURITY: that endpoint is authenticated only by an agent-worker JWT and +/// returns the raw `global_settings` value for the requested key. Because the +/// policy is deny-by-exception (anything not listed here is readable), every +/// setting that holds an instance secret or credential an agent worker does not +/// need MUST be listed below. Missing one discloses it to every agent worker — +/// `jwt_secret` is the worst case (a token holder could forge a superadmin JWT), +/// but `oauths`, `smtp_settings`, `secret_backend`, object-store credentials, +/// etc. are instance-wide secrets too. +/// +/// NOT blocked, on purpose: the operational credentials an agent worker loads to +/// run jobs (`license_key`, `hub_api_secret`, `sandbox_registry_auth`, +/// `powershell_repo_pat`, `npmrc`, ...). Those are already within an agent +/// worker's trust boundary, and blocking them breaks worker startup or +/// dependency installation. When adding a new setting that stores a secret the +/// server keeps to itself, add it here. +pub const AGENT_WORKER_BLOCKED_SETTINGS: &[&str] = &[ + // Instance identity / auth secrets — disclosure enables privilege escalation + // or impersonation. + JWT_SECRET_SETTING, + OAUTH_SETTING, + SMTP_SETTING, + SCIM_TOKEN_SETTING, + SAML_METADATA_SETTING, + SECRET_BACKEND_SETTING, + GITHUB_ENTERPRISE_APP_SETTING, + OBJECT_STORE_CONFIG_SETTING, + AI_CONFIG_SETTING, + TEAMS_SETTING, + INDEXER_SETTING, + // Server-only configs that may embed credentials, webhook URLs or tokens and + // are never loaded by an agent worker. + CRITICAL_ERROR_CHANNELS_SETTING, + INSTANCE_EVENTS_WEBHOOK_SETTING, + OTEL_SETTING, + OTEL_TRACING_PROXY_SETTING, +]; + +/// Whether an agent worker may read the given global setting over HTTP. +/// Deny-by-exception: everything is readable except [`AGENT_WORKER_BLOCKED_SETTINGS`]. +pub fn is_setting_readable_by_agent_worker(name: &str) -> bool { + !AGENT_WORKER_BLOCKED_SETTINGS.contains(&name) +} + use std::sync::atomic::AtomicBool; lazy_static::lazy_static! { @@ -265,6 +317,62 @@ pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_workers_can_read_operational_settings() { + // Operational knobs and the credentials a worker needs to run jobs are + // intentionally NOT blocked. Deny-by-exception also means an arbitrary + // unlisted key is readable. + for key in [ + NPMRC_SETTING, + PIP_INDEX_URL_SETTING, + JOB_ISOLATION_SETTING, + LICENSE_KEY_SETTING, + HUB_API_SECRET_SETTING, + SANDBOX_REGISTRY_AUTH_SETTING, + POWERSHELL_REPO_PAT_SETTING, + "some_future_operational_setting", + ] { + assert!( + is_setting_readable_by_agent_worker(key), + "'{key}' must remain readable by agent workers" + ); + } + } + + #[test] + fn agent_workers_cannot_read_instance_secrets() { + // Disclosing any of these to a remote worker enables privilege + // escalation (jwt_secret -> forged superadmin JWT) or leaks instance + // secrets. They must never be served by the agent-worker endpoint. + for key in [ + JWT_SECRET_SETTING, + OAUTH_SETTING, + SMTP_SETTING, + SCIM_TOKEN_SETTING, + SAML_METADATA_SETTING, + SECRET_BACKEND_SETTING, + GITHUB_ENTERPRISE_APP_SETTING, + OBJECT_STORE_CONFIG_SETTING, + AI_CONFIG_SETTING, + TEAMS_SETTING, + INDEXER_SETTING, + CRITICAL_ERROR_CHANNELS_SETTING, + INSTANCE_EVENTS_WEBHOOK_SETTING, + OTEL_SETTING, + OTEL_TRACING_PROXY_SETTING, + ] { + assert!( + !is_setting_readable_by_agent_worker(key), + "'{key}' is an instance secret and must not be readable by agent workers" + ); + } + } +} + pub async fn set_value_in_global_settings( db: &Pool, setting_name: &str, From 5d553b81c06664aab61131a93b198575c088d12d Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:51:06 +0200 Subject: [PATCH 3/4] feat(ai-chat): summary-based conversation compaction (#9645) * feat(ai-chat): summary-based conversation compaction Replace drop-oldest compaction with summary-based partial compaction: when a send would cross the context-window trigger, summarize the older prefix into one message and keep the recent tail verbatim, replacing the prefix in both the model context and the visible transcript with a collapsible boundary. Drop-oldest remains a fallback; a circuit breaker disables the summary round-trip after repeated failures. Co-Authored-By: Claude Opus 4.8 (1M context) * nit * fix(ai-chat): address review findings on summary compaction - Stop during an in-flight summary no longer falls through to a destructive drop-oldest compaction. The aborted controller short-circuits the fallback and its save, so the cancel path rolls the unsent turn back cleanly instead of permanently dropping older history (P1). - Preserve the original chat title across compaction: once the summary boundary leads the transcript, reuse the title computed before compaction rather than re-deriving it from the first surviving tail message (P2). - Strip every block from the model's summary, not just the first, so extra scratchpad blocks can't leak into context (P2). - Reindent AIChatMessage.svelte / ContextUsageIndicator.svelte (prettier). Adds regression tests for the abort path, title preservation, and multi-analysis stripping. Co-Authored-By: Claude Opus 4.8 * nit --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../copilot/chat/AIChatDisplay.svelte | 17 +- .../copilot/chat/AIChatManager.svelte.ts | 197 ++++++++++++++++-- .../copilot/chat/AIChatManager.test.ts | 144 ++++++++++++- .../copilot/chat/AIChatMessage.svelte | 197 +++++++++--------- .../copilot/chat/CompactionBoundary.svelte | 33 +++ .../copilot/chat/ContextManager.svelte.ts | 34 +-- .../copilot/chat/ContextUsageIndicator.svelte | 22 +- .../copilot/chat/HistoryManager.svelte.ts | 16 +- .../copilot/chat/HistoryManager.test.ts | 27 +++ .../copilot/chat/compactionPrompt.test.ts | 64 ++++++ .../copilot/chat/compactionPrompt.ts | 124 +++++++++++ .../src/lib/components/copilot/chat/shared.ts | 17 +- 12 files changed, 735 insertions(+), 157 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/CompactionBoundary.svelte create mode 100644 frontend/src/lib/components/copilot/chat/compactionPrompt.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/compactionPrompt.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index cd9c50d1c2..e9ac5eed16 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -464,11 +464,13 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {:else} {/if} @@ -538,8 +540,6 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {#if inputPreface} {@render inputPreface()} {/if} - - {/if} +
+ +
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index cb775a6084..0eb9a79707 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -36,8 +36,13 @@ import { loadApiTools } from './api/apiTools' import { prepareScriptUserMessage } from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { workspaceAIClients } from '../lib' +import { workspaceAIClients, getNonStreamingCompletion } from '../lib' import { getKnownModelContextWindow } from '../modelConfig' +import { + getCompactionSummaryPrompt, + formatCompactSummary, + buildSummaryMessageContent +} from './compactionPrompt' import { dfs } from '$lib/components/flows/previousResults' import { getStringError } from './utils' import { type PasteAttachment } from './pasteTokens' @@ -81,15 +86,25 @@ import { isGlobalAiEnabled } from './global/gate' import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userScopedStorage' import { getLocalSetting, storeLocalSetting } from '$lib/utils' -// Drop-oldest compaction of the stored history: once the projected request -// size (contextTokens — the provider's report when current, a fresh chars/4 -// estimate otherwise — plus the new user message) reaches the trigger ratio -// of the model's context window, head messages are dropped until roughly the -// target ratio. The trigger headroom absorbs what the projection cannot see — -// the upcoming completion and tool results, system-prompt/tool-schema changes -// from mode switches, and the estimate's chars/4 error. +// Compaction of the stored history: once the projected request size +// (contextTokens — the provider's report when current, a fresh chars/4 +// estimate otherwise — plus the new user message) reaches the trigger ratio of +// the model's context window, the older prefix is summarized into a single +// message while the recent tail is kept verbatim, bringing the history down to +// roughly the target ratio. The trigger headroom absorbs what the projection +// cannot see — the upcoming completion and tool results, system-prompt/tool- +// schema changes from mode switches, and the estimate's chars/4 error. const COMPACTION_TRIGGER_RATIO = 0.8 const COMPACTION_TARGET_RATIO = 0.7 +// Headroom reserved within the target budget for the summary message itself, so +// the summary + kept tail + overhead land under the target ratio. +const SUMMARY_OUTPUT_RESERVE_TOKENS = 8000 +// Below this many messages in the prefix there's little to gain from a summary +// round-trip; skip straight to drop-oldest. +const MIN_PREFIX_MESSAGES_TO_SUMMARIZE = 4 +// Stop attempting summarization after this many consecutive failures and use +// drop-oldest directly; a successful summarization resets the counter. +const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3 // Abort reason for a deliberate user cancel (Esc / Stop). Programmatic cancels // (panel teardown, save-and-clear) pass their own reason, so the queued-message // flush can tell "the user wants to move on" from "the turn was torn down". @@ -242,6 +257,13 @@ export class AIChatManager { * (provider never reported, turn failed, history rewound). Never holds a * guess: readers go through `contextTokens`, which estimates lazily. */ contextUsage = $state(undefined) + // Circuit breaker for summary-based compaction: after repeated failures the + // summary round-trip is skipped in favor of drop-oldest. Reset on any + // successful summarization. Not persisted — a fresh load gets a fresh chance. + private consecutiveCompactionFailures = 0 + // True while the summarization round-trip is in flight, so the UI can show a + // "Compacting conversation" label on the processing indicator. + compacting = $state(false) autonomyMode = $state(getPersistedAutonomyMode()) autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) autoAcceptEditsActive = $derived( @@ -397,6 +419,122 @@ export class AIChatManager { return freed } + /** + * Summary-based partial compaction. Summarizes the older PREFIX of the stored + * history into a single user message and keeps the recent tail verbatim, + * bringing the history down to roughly the target ratio while preserving the + * intent, decisions, and recent work that drop-oldest would discard. + * + * The tail grows from the most recent message until it fills `tailBudget`, + * then snaps forward to a user-message boundary (a leading tool/assistant + * message would dangle without the turn that introduced it). The summary + * replaces the prefix in BOTH `messages` (as a user message) and + * `displayMessages` (as a `summary` boundary); surviving tail user messages + * have their restart `index` re-based onto the new history. + * + * Returns true on success. Returns false — caller falls back to drop-oldest — + * when summarization isn't worthwhile or fails (user abort, empty summary, or + * the circuit breaker being tripped). + */ + private summarizeAndCompact = async (contextWindow: number): Promise => { + if (this.consecutiveCompactionFailures >= MAX_CONSECUTIVE_COMPACTION_FAILURES) { + return false + } + const abortController = this.abortController + if (!abortController) { + return false + } + + const tailBudget = + contextWindow * COMPACTION_TARGET_RATIO - + SUMMARY_OUTPUT_RESERVE_TOKENS - + this.estimateOverheadTokens() + if (tailBudget <= 0) { + return false + } + + const last = this.messages.length - 1 + if (last < 1) { + return false + } + + // Grow the tail from the most recent message downward while it fits the + // budget; always keep at least the last message. + let keepFrom = last + let tailTokens = 0 + for (let i = last; i >= 1; i--) { + const t = this.estimateMessagesTokens([this.messages[i]]) + if (i < last && tailTokens + t > tailBudget) { + break + } + tailTokens += t + keepFrom = i + } + // The tail must start on a user message — move the boundary forward over + // any leading tool/assistant messages, folding them into the prefix. + while (keepFrom < last && this.messages[keepFrom].role !== 'user') { + keepFrom++ + } + + const prefix = this.messages.slice(0, keepFrom) + const tail = this.messages.slice(keepFrom) + if (prefix.length < MIN_PREFIX_MESSAGES_TO_SUMMARIZE || tail.length === 0) { + return false + } + + // The user message at the boundary has a display counterpart with the same + // index; resolve it before any mutation so a corrupt transcript can never + // result from an unexpected miss. + const displayKeepFrom = this.displayMessages.findIndex( + (m) => m.role === 'user' && m.index >= keepFrom + ) + if (displayKeepFrom === -1) { + this.consecutiveCompactionFailures++ + return false + } + + this.compacting = true + try { + const raw = await getNonStreamingCompletion( + [...prefix, { role: 'user', content: getCompactionSummaryPrompt() }], + abortController + ) + const formatted = formatCompactSummary(raw ?? '') + if (!formatted) { + this.consecutiveCompactionFailures++ + return false + } + + this.messages = [{ role: 'user', content: buildSummaryMessageContent(formatted) }, ...tail] + + // Replace the summarized display prefix with the boundary marker and + // re-base the surviving tail's restart indices (the summary occupies + // slot 0, so the tail now starts at slot 1). + this.displayMessages = [ + { role: 'summary', content: formatted }, + ...this.displayMessages + .slice(displayKeepFrom) + .map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m)) + ] + + // The provider report described the pre-compaction history; the new + // history is much smaller, so clear it and let readers re-estimate. + this.contextUsage = undefined + this.consecutiveCompactionFailures = 0 + return true + } catch (err) { + // A user Stop aborts the in-flight summary — that's a turn cancel, not a + // compaction failure, so it doesn't count toward the circuit breaker. + if (!abortController.signal.aborted) { + console.error('Conversation summarization failed', err) + this.consecutiveCompactionFailures++ + } + return false + } finally { + this.compacting = false + } + } + loadApiTools = async () => { try { this.apiTools = await loadApiTools() @@ -1156,7 +1294,6 @@ export class AIChatManager { // not the expanded LLM text, plus the rollback anchor after the user turn. const sentInstructions = this.instructions const sentPastes = pastes - const displayLenAfterUser = this.displayMessages.length // The LLM gets the full pasted content; the display message above keeps // the compact tokens + registry so the bubble can render/expand chips. const oldInstructions = expanded(chatDraft(this.instructions, pastes)) @@ -1226,22 +1363,38 @@ export class AIChatManager { contextWindow !== undefined && projectedContextTokens >= contextWindow * COMPACTION_TRIGGER_RATIO ) { - const freed = this.compactOldestMessages( - projectedContextTokens - contextWindow * COMPACTION_TARGET_RATIO - ) - // A report stays meaningful only debited by what was dropped; the - // estimate path needs no bookkeeping — the next read re-estimates - // the compacted history. chars/4 can underestimate the freed - // tokens, which errs toward compacting again — never toward - // overflowing. - if (this.contextUsage !== undefined) { - this.contextUsage = Math.max(0, this.contextUsage - freed) + // Preferred path: summarize the older prefix, keep the recent tail. + const summarized = await this.summarizeAndCompact(contextWindow) + // A Stop during the in-flight summary aborts this turn's controller; + // summarizeAndCompact then returns false without touching history. Skip + // the drop-oldest fallback (and its save) — it would destructively + // compact a conversation the user only meant to cancel, and the request + // can't run on an aborted controller anyway. The cancel path below rolls + // the pushed turn back cleanly on its own. + if (!this.abortController?.signal.aborted) { + if (!summarized) { + // Fallback when summarization isn't worthwhile or fails: drop the + // oldest messages. A report stays meaningful only debited by what + // was dropped; the estimate path needs no bookkeeping — the next + // read re-estimates the compacted history. chars/4 can + // underestimate the freed tokens, which errs toward compacting + // again — never toward overflowing. + const freed = this.compactOldestMessages( + projectedContextTokens - contextWindow * COMPACTION_TARGET_RATIO + ) + if (this.contextUsage !== undefined) { + this.contextUsage = Math.max(0, this.contextUsage - freed) + } + } + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) } - await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) } - // Rollback anchor for restoreUnsentTurn: captured after compaction so it - // indexes into the (possibly compacted) stored history. + // Rollback anchors for restoreUnsentTurn: captured after compaction so + // they index into the (possibly compacted) stored history. The summary + // path shrinks displayMessages too, so the display anchor must also be + // read here, not before compaction. const modelLenAfterUser = this.messages.length + const displayLenAfterUser = this.displayMessages.length const params: { messages: ChatCompletionMessageParam[] diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 01929cefc7..16a08d7344 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ sendUserToast: vi.fn(), getOpenaiClient: vi.fn(), getAnthropicClient: vi.fn(), + getNonStreamingCompletion: vi.fn(), runChatLoop: vi.fn() })) @@ -60,7 +61,8 @@ vi.mock('../lib', () => ({ subscribe: () => () => undefined, getOpenaiClient: mocks.getOpenaiClient, getAnthropicClient: mocks.getAnthropicClient - } + }, + getNonStreamingCompletion: mocks.getNonStreamingCompletion })) vi.mock('./api/apiTools', () => ({ @@ -770,6 +772,146 @@ describe('AIChatManager context compaction', () => { await manager.saveAndClear() expect(manager.contextUsage).toBeUndefined() }) + + // gpt-4o resolves to a known 128k window (modelConfig unmocked): trigger at + // ~102k, target ~90k. With a summary reserve of 8k the tail budget is ~76k. + const gpt4oModel = { provider: 'openai', model: 'gpt-4o' } + + // Older prefix (4 messages, ~25k tokens each = 100k chars) plus a recent + // user+assistant pair that fits the tail budget. After the new user turn is + // pushed the budget keeps [recentQ, recentA, new] verbatim and summarizes the + // four old messages. + function seedForSummary(manager: AIChatManager) { + manager.messages = [ + { role: 'user', content: 'OLD1' + 'a'.repeat(100_000) }, + { role: 'assistant', content: 'OLD2' + 'b'.repeat(100_000) }, + { role: 'user', content: 'OLD3' + 'c'.repeat(100_000) }, + { role: 'assistant', content: 'OLD4' + 'd'.repeat(100_000) }, + { role: 'user', content: 'recentQ' + 'e'.repeat(80_000) }, + { role: 'assistant', content: 'recentA' + 'f'.repeat(80_000) } + ] + manager.displayMessages = [ + { role: 'user', content: 'old1', index: 0 }, + { role: 'assistant', content: 'old2' }, + { role: 'user', content: 'old3', index: 2 }, + { role: 'assistant', content: 'old4' }, + { role: 'user', content: 'recentQ', index: 4 }, + { role: 'assistant', content: 'recentA' } + ] + manager.instructions = 'next question' + } + + it('summarizes the older prefix and keeps the recent tail verbatim', async () => { + mocks.getCurrentModel.mockReturnValue(gpt4oModel) + mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) + mocks.getNonStreamingCompletion.mockResolvedValue( + 'scratchpadSUMMARY TEXT' + ) + const manager = new AIChatManager() + seedForSummary(manager) + + await manager.sendRequest() + + // The prefix (the four OLD messages) was sent to the summarizer, followed + // by the summary-instruction user message. + expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) + const summaryReq = mocks.getNonStreamingCompletion.mock.calls[0][0] + expect(summaryReq).toHaveLength(5) + expect(summaryReq[0].content).toContain('OLD1') + expect(summaryReq[3].content).toContain('OLD4') + expect(summaryReq[4].content).toContain('detailed summary') + + // The request that went out begins with the summary user message, then the + // recent tail verbatim, then the new question. + const sent = mocks.runChatLoop.mock.calls[0][0].messages + expect(sent).toHaveLength(4) + expect(sent[0].role).toBe('user') + expect(sent[0].content).toContain('SUMMARY TEXT') + expect(sent[0].content).toContain('continued from a previous conversation') + expect(sent[0].content).not.toContain('scratchpad') + expect(sent[1].content).toContain('recentQ') + + // The display transcript replaces the summarized bubbles with one boundary + // and re-bases the surviving tail's restart indices onto the new history. + expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', content: 'SUMMARY TEXT' }) + const recentQDisplay = manager.displayMessages.find( + (m) => m.role === 'user' && m.content === 'recentQ' + ) + expect(recentQDisplay && 'index' in recentQDisplay ? recentQDisplay.index : undefined).toBe(1) + // No report describes the new history, so the readable number re-estimates + // the now-small compacted context. + expect(manager.contextUsage).toBeUndefined() + }) + + it('falls back to drop-oldest when summarization fails', async () => { + mocks.getCurrentModel.mockReturnValue(gpt4oModel) + mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) + mocks.getNonStreamingCompletion.mockRejectedValue(new Error('summary boom')) + const manager = new AIChatManager() + seedForSummary(manager) + + await manager.sendRequest() + + // Summarization was attempted, then the request still went out — via + // drop-oldest, so no summary boundary anywhere. + expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) + expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) + const sent = mocks.runChatLoop.mock.calls[0][0].messages + expect(sent[0].content).not.toContain('continued from a previous conversation') + expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false) + }) + + it('skips summarization (drop-oldest) when the prefix is too small', async () => { + mocks.getCurrentModel.mockReturnValue(anthropicModel) + mocks.tryGetCurrentModel.mockReturnValue(anthropicModel) + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, + { role: 'assistant', content: 'b'.repeat(400_000) }, + { role: 'user', content: 'c'.repeat(400) } + ] + manager.contextUsage = 850_000 + manager.instructions = 'next question' + + await manager.sendRequest() + + // A two-message prefix isn't worth a summary round-trip. + expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled() + expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false) + }) + + it('does not drop-oldest compact when the user stops during summarization', async () => { + mocks.getCurrentModel.mockReturnValue(gpt4oModel) + mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) + // The user hits Stop while the summary request is in flight: it aborts the + // turn's controller and rejects. + mocks.getNonStreamingCompletion.mockImplementation( + async (_msgs: any, ac: AbortController) => { + ac.abort('user_cancelled') + throw new Error('aborted') + } + ) + // With the controller already aborted, the real request returns nothing; + // mirror that so the turn takes the cancel/rollback path. + mocks.runChatLoop.mockImplementation(async () => ({ + addedMessages: [], + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + lastIterationUsage: null, + hitMaxIterations: false + })) + const manager = new AIChatManager() + seedForSummary(manager) + + await manager.sendRequest() + + // Summarization was attempted and aborted, but the abort must NOT trigger a + // destructive drop-oldest fallback: the full prefix survives and the unsent + // turn is rolled back to the pre-send history (the head pair is still there). + expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) + expect(manager.messages).toHaveLength(6) + expect(manager.messages[0].content).toContain('OLD1') + expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false) + }) }) const assistantToolCall = (id: string): ChatCompletionMessageParam => ({ diff --git a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte index 76be72b52f..4e2df28a50 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte @@ -9,6 +9,7 @@ import AIChatInput from './AIChatInput.svelte' import type { ContextElement } from './context' import ToolExecutionDisplay from './ToolExecutionDisplay.svelte' + import CompactionBoundary from './CompactionBoundary.svelte' import { messageDraft, segments } from './chatDraft' import { lineCountLabel } from './pasteTokens' @@ -50,103 +51,111 @@ } -
0 && 'mt-4 mb-6', - isLast && '!mb-12', - message.role !== 'user' ? 'cursor-default' : 'cursor-pointer' - )} - role="button" - tabindex="0" - onclick={() => editMessage()} - onkeydown={() => {}} -> - {#if message.role === 'user' && message.contextElements && editingMessageIndex !== messageIndex} -
- {#each message.contextElements as element} - - {/each} -
- {/if} - {#if message.role === 'user' && editingMessageIndex === messageIndex} -
- (editingMessageIndex = null)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - editingMessageIndex = null - } - }} - onEditEnd={() => (editingMessageIndex = null)} - /> -
- {:else} -
- {#if message.role === 'assistant'} -
- {:else if message.role === 'tool'} -
- {:else} -
- {#each segments(messageDraft(message)) as seg}{#if seg.type === 'text'}{seg.value}{:else if expandedPastes.has(seg.att.id)}{seg.att.content}{:else}{/if}{/each} -
- {/if} -
- {/if} - {#if message.role === 'user' && message.snapshot} -
- Saved {message.snapshot.type === 'flow' ? 'a flow' : 'an app'} snapshot - {seg.att.content}{:else}{/if}{/each} +
+ {/if} +
+ {/if} + {#if message.role === 'user' && message.snapshot} +
- Revert + Saved {message.snapshot.type === 'flow' ? 'a flow' : 'an app'} snapshot + +
+ {/if} +
+ {#if message.role === 'user' && message.error} +
+
{/if} - -{#if message.role === 'user' && message.error} -
- -
{/if} diff --git a/frontend/src/lib/components/copilot/chat/CompactionBoundary.svelte b/frontend/src/lib/components/copilot/chat/CompactionBoundary.svelte new file mode 100644 index 0000000000..af9888bc93 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/CompactionBoundary.svelte @@ -0,0 +1,33 @@ + + +
+
+
+ +
+
+ {#if expanded} +
+ {content} +
+ {/if} +
diff --git a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts index a69d6670b4..4216b6cfa0 100644 --- a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts @@ -428,21 +428,25 @@ export default class ContextManager { displayMessages: DisplayMessage[], dbSchemas: DBSchemas ): DisplayMessage[] { - return displayMessages.map((m) => ({ - ...m, - contextElements: - m.role !== 'tool' && m.contextElements - ? m.contextElements.map((c) => - c.type === 'db' - ? { - type: 'db', - title: c.title, - schema: dbSchemas[c.title] - } - : c - ) - : undefined - })) + return displayMessages.map((m) => { + // Only user/assistant messages carry contextElements; tool and summary + // messages pass through untouched. + if ((m.role === 'user' || m.role === 'assistant') && m.contextElements) { + return { + ...m, + contextElements: m.contextElements.map((c) => + c.type === 'db' + ? { + type: 'db' as const, + title: c.title, + schema: dbSchemas[c.title] + } + : c + ) + } + } + return m + }) } setSelectedModuleContext( diff --git a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte index 4fe1c342f0..f7681e9852 100644 --- a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte @@ -15,13 +15,9 @@ // one describes the current history (one turn stale by nature), otherwise // a live chars/4 estimate of the stored context. let usedTokens = $derived(Math.round(aiChatManager.contextTokens)) - // With a known window, only surface once the conversation actually fills it; - // without one there is no threshold to compare against, so always show. - let visible = $derived( - usedTokens > 0 && - aiChatManager.messages.length > 0 && - (contextWindow === undefined || usedTokens >= contextWindow * 0.5) - ) + // Always surface usage once a conversation has started, at any fill level, so + // the user can watch context grow toward the compaction threshold. + let visible = $derived(usedTokens > 0 && aiChatManager.messages.length > 0) function formatTokenCount(tokens: number): string { if (tokens >= 1_000_000) { @@ -35,11 +31,9 @@ {#if visible} -
- - context window usage: ~{formatTokenCount(usedTokens)}{contextWindow - ? ` / ${formatTokenCount(contextWindow)}` - : ''} - -
+ + Context usage: ~{formatTokenCount(usedTokens)}{contextWindow + ? ` / ${formatTokenCount(contextWindow)}` + : ''} + {/if} diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index c11c0c3907..9173e4c441 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -179,9 +179,19 @@ export default class HistoryManager { contextUsage?: number ) { if (displayMessages.length > 0) { - // Expand any collapsed-paste tokens so the title is readable text, not - // the chip label + its zero-width id chars. - const title = expanded(messageDraft(displayMessages[0])).slice(0, 50) + // Compaction replaces the original first message with a summary boundary. + // Re-deriving the title would then shift it to the first surviving tail + // message, so once that boundary leads the transcript, keep the title + // computed before compaction. Otherwise derive it from the first message, + // expanding collapsed-paste tokens so it reads as text rather than the + // chip label + its zero-width id chars. + const existingTitle = this.savedChats[this.currentChatId]?.title + const title = + displayMessages[0].role === 'summary' && existingTitle !== undefined + ? existingTitle + : expanded( + messageDraft(displayMessages.find((m) => m.role !== 'summary') ?? displayMessages[0]) + ).slice(0, 50) // we don't want to save the snapshot in the history const updatedChat = { actualMessages: $state.snapshot(messages), diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts index 96e304fa98..38fb7ecf0d 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts @@ -114,3 +114,30 @@ describe('HistoryManager legacy chat-history migration', () => { expect(await countChats('copilot-chat-history::admin@test')).toBe(1) }) }) + +describe('HistoryManager title across compaction', () => { + it('keeps the original title once a summary boundary leads the transcript', async () => { + const hm = new HistoryManager() + await hm.init() + const id = hm.getCurrentChatId() + + // First save derives the title from the first user message. + await hm.save( + [{ role: 'user', content: 'original first question', index: 0 }] as DisplayMessage[], + [] as ChatCompletionMessageParam[] + ) + expect(hm.getAllSavedChats().find((c) => c.id === id)?.title).toBe('original first question') + + // After compaction the transcript leads with a summary boundary; deriving + // the title now would shift it to the surviving tail message. It must stay + // the title computed before compaction. + await hm.save( + [ + { role: 'summary', content: 'summary of the earlier conversation' }, + { role: 'user', content: 'a much later follow-up', index: 1 } + ] as DisplayMessage[], + [] as ChatCompletionMessageParam[] + ) + expect(hm.getAllSavedChats().find((c) => c.id === id)?.title).toBe('original first question') + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/compactionPrompt.test.ts b/frontend/src/lib/components/copilot/chat/compactionPrompt.test.ts new file mode 100644 index 0000000000..2161182594 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/compactionPrompt.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + buildSummaryMessageContent, + formatCompactSummary, + getCompactionSummaryPrompt +} from './compactionPrompt' + +describe('formatCompactSummary', () => { + it('strips the analysis scratchpad and unwraps the summary block', () => { + const raw = ` +chronological thinking the model should not keep + + +1. Primary Request and Intent: build the thing +2. Pending Tasks: none +` + const formatted = formatCompactSummary(raw) + expect(formatted).not.toContain('chronological thinking') + expect(formatted).not.toContain('') + expect(formatted).not.toContain('') + expect(formatted).toContain('Primary Request and Intent: build the thing') + }) + + it('falls back to the trimmed raw text when the model omits the tags', () => { + expect(formatCompactSummary(' just a plain summary ')).toBe('just a plain summary') + }) + + it('keeps the summary even when there is no analysis block', () => { + expect(formatCompactSummary('only the summary')).toBe('only the summary') + }) + + it('collapses the blank-line runs left by stripping analysis', () => { + const raw = 'x\n\n\n\na\n\n\n\nb' + expect(formatCompactSummary(raw)).toBe('a\n\nb') + }) + + it('strips every analysis block, not just the first, when the summary is untagged', () => { + const raw = 'first\nkept one\nsecond\nkept two' + const formatted = formatCompactSummary(raw) + expect(formatted).not.toContain('first') + expect(formatted).not.toContain('second') + expect(formatted).not.toContain('') + expect(formatted).toContain('kept one') + expect(formatted).toContain('kept two') + }) +}) + +describe('buildSummaryMessageContent', () => { + it('embeds the summary and frames it as a continuation', () => { + const content = buildSummaryMessageContent('THE SUMMARY') + expect(content).toContain('THE SUMMARY') + expect(content).toContain('continued from a previous conversation') + expect(content).toContain('preserved verbatim') + }) +}) + +describe('getCompactionSummaryPrompt', () => { + it('asks for a structured, text-only summary', () => { + const prompt = getCompactionSummaryPrompt() + expect(prompt).toContain('detailed summary') + expect(prompt).toContain('') + expect(prompt).toContain('TEXT ONLY') + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/compactionPrompt.ts b/frontend/src/lib/components/copilot/chat/compactionPrompt.ts new file mode 100644 index 0000000000..010a8cf8b8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/compactionPrompt.ts @@ -0,0 +1,124 @@ +// Summary-based compaction: when a conversation approaches the model's context +// window, the older prefix is replaced by an LLM-generated structured summary +// while the recent tail is kept verbatim. The summary precedes the kept tail, +// so it is written "up to" the point of compaction — newer messages the model +// does not see here will follow it. +// +// Prompt structure and the analysis/summary split are adapted from the +// reference compaction prompt used by coding agents. + +// Reinforce text-only output. The summary call is issued without tools, but a +// strong instruction keeps weaker models from narrating a tool call instead of +// producing the summary. +const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. + +- You already have all the context you need in the conversation above. +- Your entire response must be plain text: an block followed by a block. + +` + +const NO_TOOLS_TRAILER = + '\n\nREMINDER: Respond with plain text only — an block followed by a block.' + +// The block is a drafting scratchpad that formatCompactSummary() +// strips before the summary reaches context. +const SUMMARY_PROMPT = `Your task is to create a detailed summary of the conversation so far. This summary will be placed at the start of a continuing session; newer messages that build on this context will follow after it (you do not see them here). Summarize thoroughly so that someone reading only your summary and then the newer messages can fully understand what happened and continue the work without losing context. + +This is a conversation with Windmill's global workspace assistant. It inspects workspace items and authors them as per-user drafts — scripts, flows, apps, resources, variables, triggers, and schedules — then deploys those drafts and test-runs scripts and flows. It works with items by their workspace path (e.g. \`u/alice/sync_orders\`, \`f/team/my_flow\`); it does NOT edit files on a filesystem. Frame the summary in those terms. + +Before providing your final summary, wrap your analysis in tags to organize your thoughts. In your analysis: + +1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify: + - The user's explicit requests and intents + - Your approach to addressing the user's requests + - Key decisions, technical concepts and code patterns + - Specific details: workspace item paths and kinds (script / flow / app / resource / variable / trigger / schedule), code snippets for runnables, and the exact actions taken (drafts created or updated, items deployed, test runs and their results) + - Errors you ran into and how you fixed them + - Specific user feedback, especially if the user told you to do something differently +2. Double-check for technical accuracy and completeness. + +Your summary should include the following sections: + +1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail. +2. Key Technical Concepts: List important technical concepts, technologies, and frameworks discussed. +3. Workspace Items and Code: Enumerate the workspace items inspected, drafted, updated, deployed, or test-run — each by its path and kind (script / flow / app / resource / variable / trigger / schedule) — noting what was done and why. Include full code snippets for runnables (script code, flow inline scripts, app inline runnables) wherever code was written or changed. +4. Errors and fixes: List errors encountered and how they were fixed, including any user feedback. +5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. +6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the user's feedback and changing intent. +7. Pending Tasks: Outline any pending tasks you have explicitly been asked to work on. +8. Current Work: Describe precisely what was being worked on immediately before this summary, paying special attention to the most recent messages. Include item paths and code snippets where applicable. +9. Context for Continuing Work: Summarize any context, decisions, or state needed to understand and continue the work in subsequent messages — including which items are still drafts versus deployed and the exact paths involved. If there is a clear next step directly in line with the user's most recent explicit request, state it and include a direct quote from the most recent conversation showing where you left off. + +Structure your output like this: + + +[Your thought process, ensuring all points are covered thoroughly and accurately] + + + +1. Primary Request and Intent: + [Detailed description] + +2. Key Technical Concepts: + - [Concept] + +3. Workspace Items and Code: + - [path + kind, e.g. u/alice/sync_orders (script)] + - [What was done: read / draft created / draft updated / deployed / test-run result] + - [Why it matters] + - [Code snippet, for runnables] + +4. Errors and fixes: + - [Error]: [How you fixed it] + +5. Problem Solving: + [Description] + +6. All user messages: + - [Non-tool-result user message] + +7. Pending Tasks: + - [Task] + +8. Current Work: + [Precise description of current work] + +9. Context for Continuing Work: + [Key context, decisions, or state needed to continue] + + +Please provide your summary following this structure, ensuring precision and thoroughness.` + +/** Prompt sent as the final user message of the summarization request. */ +export function getCompactionSummaryPrompt(): string { + return NO_TOOLS_PREAMBLE + SUMMARY_PROMPT + NO_TOOLS_TRAILER +} + +/** + * Strips the drafting scratchpad and unwraps the block. + * Falls back to the trimmed raw text when the model didn't use the tags, so a + * well-formed-but-untagged summary is still usable. + */ +export function formatCompactSummary(raw: string): string { + let formatted = raw.replace(/[\s\S]*?<\/analysis>/gi, '') + + const summaryMatch = formatted.match(/([\s\S]*?)<\/summary>/i) + if (summaryMatch) { + formatted = (summaryMatch[1] ?? '').trim() + } + + // Collapse the blank-line runs left behind by stripping the analysis block. + return formatted.replace(/\n{3,}/g, '\n\n').trim() +} + +/** + * Wraps a formatted summary as the content of the user message that replaces + * the summarized prefix in the conversation. + */ +export function buildSummaryMessageContent(formattedSummary: string): string { + return `This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation. Recent messages after the summary are preserved verbatim. + +${formattedSummary} + +Continue the conversation from where it left off. Do not re-introduce the summary or recap it to the user; pick up the work as if the break never happened.` +} diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index f32f266657..aecea1ddfc 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -525,7 +525,22 @@ export type AssistantDisplayMessage = BaseDisplayMessage & { streaming?: boolean } -export type DisplayMessage = UserDisplayMessage | ToolDisplayMessage | AssistantDisplayMessage +/** + * Compaction boundary: replaces the summarized prefix in BOTH displayMessages + * and the API messages (where it is a plain user message). It carries no index + * because it is never a restart target — only the surviving tail's user + * messages are rewound to. + */ +export type SummaryDisplayMessage = { + role: 'summary' + content: string +} + +export type DisplayMessage = + | UserDisplayMessage + | ToolDisplayMessage + | AssistantDisplayMessage + | SummaryDisplayMessage // A tool message whose askUserQuestion is still awaiting an answer: the AI loop // is paused on the user. Drives the question card's interactivity, the From 796230d90a7e6d1debc15e139ab708881e527862 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 18 Jun 2026 16:59:11 +0200 Subject: [PATCH 4/4] fix(workspaces): add instance setting to disable workspace invite/add emails (#9643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workspaces): add skip_email option to invite_user and add_user endpoints The workspace invite_user and add_user API endpoints unconditionally sent notification emails when SMTP was configured, with no way to suppress them per-request. This is noise for automated workflows that programmatically add users to workspaces. Add an optional `skip_email: Option` field to `NewWorkspaceInvite` and `NewWorkspaceUser`, following the existing pattern on `NewUser` used by POST /api/users/create, and guard the `send_email_if_possible` calls with `if !nu.skip_email.unwrap_or(false)`. The field is optional, so existing clients are unaffected. The auto-add code paths in workspaces_ee.rs (domain-based and instance-group auto-add) are auto-triggered and take no API parameter, so they are left as-is. Fixes WIN-2068 Co-Authored-By: Claude Opus 4.8 (1M context) * feat(workspaces): make workspace invite/add emails toggleable via instance setting Replace the per-request skip_email approach with an instance-level setting `disable_workspace_invite_emails`. When enabled, the email notifications sent by the workspace invite_user and add_user endpoints are suppressed. Useful for instances where users are added programmatically (e.g. CI pipelines that fork workspaces and add users) and the invite emails are noise. Backend: - Add `DISABLE_WORKSPACE_INVITE_EMAILS_SETTING` global setting constant. - Guard the `send_email_if_possible` calls in invite_user and add_user with a read of that setting (via the existing `load_value_from_global_settings` helper). Defaults to false, so existing behavior is unchanged. - Revert the per-request `skip_email` field on NewWorkspaceInvite / NewWorkspaceUser and the corresponding openapi additions. Frontend: - Expose the setting as a boolean toggle in the SMTP tab of the instance settings (superadmin). The auto-add paths in workspaces_ee.rs are unaffected. Fixes WIN-2068 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): gate disable_workspace_invite_emails toggle behind EE Email delivery (send_email_if_possible) is a no-op outside the EE/private build, so the toggle has no effect on a pure-OSS instance. Add `ee_only: ''` to match the sibling SMTP settings: the toggle is grayed out (with an EE badge) on non-EE instances instead of rendering as an active no-op control. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): don't EE-gate disable_workspace_invite_emails toggle The earlier ee_only addition was based on the false premise that the workspace invite/add emails are license-gated. They are not: SMTP configuration (SmtpSettings) and email sending (send_email_if_possible) have no enterpriseLicense check — they only require the closed-source build with SMTP configured. The sibling smtp_settings carries ee_only: '' but its smtp_connect field renders no SettingCard label, so that flag is inert (no badge, no disable). On a plain boolean field ee_only is fully active, which incorrectly grayed out the toggle and showed an EE badge. Drop ee_only so the control matches the actual non-license-gated behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../windmill-api-workspaces/src/workspaces.rs | 56 +++++++++++++------ .../windmill-common/src/global_settings.rs | 1 + .../src/lib/components/instanceSettings.ts | 8 +++ 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 788c090aa2..6387bb27c8 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -48,7 +48,9 @@ use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; use windmill_common::PgDatabase; use windmill_common::{ error::{Error, JsonResult, Result}, - global_settings::AUTOMATE_USERNAME_CREATION_SETTING, + global_settings::{ + AUTOMATE_USERNAME_CREATION_SETTING, DISABLE_WORKSPACE_INVITE_EMAILS_SETTING, + }, oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH, utils::{paginate, rd_string, require_admin, Pagination}, }; @@ -5354,6 +5356,20 @@ async fn unarchive_workspace( Ok(format!("Unarchived workspace {}", &w_id)) } +/// Whether the instance is configured to suppress the email notifications sent +/// when a user is invited or added to a workspace. Defaults to false (emails on). +async fn workspace_invite_emails_disabled(db: &DB) -> Result { + Ok( + windmill_common::global_settings::load_value_from_global_settings( + db, + DISABLE_WORKSPACE_INVITE_EMAILS_SETTING, + ) + .await? + .and_then(|v| v.as_bool()) + .unwrap_or(false), + ) +} + async fn invite_user( ApiAuthed { username, is_admin, .. }: ApiAuthed, Extension(db): Extension, @@ -5412,16 +5428,18 @@ async fn invite_user( tx.commit().await?; - send_email_if_possible( - &format!("Invited to Windmill's workspace: {w_id}"), - &format!( - "You have been granted access to Windmill's workspace {w_id} + if !workspace_invite_emails_disabled(&db).await? { + send_email_if_possible( + &format!("Invited to Windmill's workspace: {w_id}"), + &format!( + "You have been granted access to Windmill's workspace {w_id} If you do not have an account on {}, login with SSO or ask an admin to create an account for you.", - (**BASE_URL.load()).clone() - ), - &nu.email, - ); + (**BASE_URL.load()).clone() + ), + &nu.email, + ); + } webhook.send_instance_event(InstanceEvent::UserInvitedWorkspace { email: nu.email.clone(), @@ -5564,17 +5582,19 @@ async fn add_user( ) .await?; - send_email_if_possible( - &format!("Added to Windmill's workspace: {w_id}"), - &format!( - "You have been granted access to Windmill's workspace {w_id} by {} + if !workspace_invite_emails_disabled(&db).await? { + send_email_if_possible( + &format!("Added to Windmill's workspace: {w_id}"), + &format!( + "You have been granted access to Windmill's workspace {w_id} by {} If you do not have an account on {}, login with SSO or ask an admin to create an account for you.", - authed.email, - (**BASE_URL.load()).clone() - ), - &nu.email, - ); + authed.email, + (**BASE_URL.load()).clone() + ), + &nu.email, + ); + } webhook.send_instance_event(InstanceEvent::UserAddedWorkspace { workspace: w_id.clone(), diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index bed78113b5..4d1d48a53d 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -76,6 +76,7 @@ pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation"; +pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails"; pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login"; pub const AUTO_LOGIN_PROVIDER_SETTING: &str = "auto_login_provider"; pub const HUB_BASE_URL_SETTING: &str = "hub_base_url"; diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 6af6926c4f..72ccbeca2b 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -523,6 +523,14 @@ export const settings: Record = { fieldType: 'smtp_connect', storage: 'setting', ee_only: '' + }, + { + label: 'Disable workspace invite emails', + description: + 'Do not send email notifications when a user is invited or added to a workspace. Useful for automated workflows that add users programmatically.', + key: 'disable_workspace_invite_emails', + fieldType: 'boolean', + storage: 'setting' } ], 'Auth/OAuth/SAML': [