Merge origin/main into fix-runnable-test-draft-app

Two conflicts in the global chat core, both in code main reshaped:

- the tool list became `SessionTool<{}>[]` and gained `globalToolsFor`, which
  supersedes the `unroutedGlobalTools` alias this branch introduced; dropped the
  alias and kept main's shape. `SessionTool<T> = Tool<T> & {...}`, so the
  `draftTarget` field this branch adds to `Tool` still applies.
- main wrapped the deploy/discard prompt rules in `when(canWriteDraft, ...)`;
  kept that and left this branch's `draft_path` addressing rule outside the
  wrap, since it governs reads too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-25 10:07:34 +02:00
co-authored by Claude Opus 5
184 changed files with 12482 additions and 11142 deletions
+1 -1
View File
@@ -295,4 +295,4 @@ jobs:
# never reaches it. Pin the target dir (matching the cache step above) so
# its own tests run off this compile rather than a second bundled build.
(cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal)
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline --all -- --nocapture --test-threads=10
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp --all -- --nocapture --test-threads=10
+4 -1
View File
@@ -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
+4 -2
View File
@@ -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"
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
@@ -98,9 +98,11 @@ export async function runEval<THelpers, TOutput>(
// Wrap tools to intercept fn calls for tracking.
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
// but the actual callbacks passed at runtime will satisfy both interfaces.
// `fn` forwards `this`: the chat loop calls it on a per-iteration copy carrying the def the
// model was sent, and a tool reading `this.def` must see that one, as it does in production.
const wrappedTools = tools.map((tool) => ({
...tool,
fn: async (p: any) => {
fn: async function (this: unknown, p: any) {
toolCallsCount++;
toolsCalled.push(tool.def.function.name);
let argumentsText = "";
@@ -120,7 +122,7 @@ export async function runEval<THelpers, TOutput>(
toolName: tool.def.function.name,
argumentsText,
});
return tool.fn(p);
return tool.fn.call(this, p);
},
}));
+21 -11
View File
@@ -1112,9 +1112,9 @@
- opens the Kafka triggers page
- does not write, deploy, or delete anything
- id: global-openpage7-compare-review
- id: global-draft-does-not-open-compare
prompt: |-
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it), then open the review page so I can look over the pending change and deploy it myself.
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it). I want to look over the change before it gets deployed.
runtime:
maxTurns: 8
validate:
@@ -1122,6 +1122,24 @@
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- open_page
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- creates the script draft without opening any workspace page
- does not deploy or delete anything
- id: global-openpage7-compare-fork-deploy
prompt: |-
I'm done with my changes in this fork and want to ship them to the parent workspace myself. Take me there.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
@@ -1131,17 +1149,9 @@
field: page
stringIncludesAnyOf:
- compare
# The eval chat is untracked (no modified-items mask), so the model must scope
# the review by passing the item it changed explicitly — an omitted mask would
# preselect every pending change in the workspace.
- tool: open_page
field: items
stringIncludesAnyOf:
- f/evals/global/compare_review_demo
skipJudge: true
judgeChecklist:
- creates the script draft, then opens the Compare & Deploy review page instead of deploying itself
- preselects only the created script on the review page
- opens Compare & Deploy only because the user explicitly wants to deploy the fork into its parent
- does not deploy or delete anything
- id: global-openpage8-runs-label-and-worker
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
"describe": {
"columns": [
{
@@ -236,5 +236,5 @@
true
]
},
"hash": "bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e"
"hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56"
}
+5
View File
@@ -15658,11 +15658,14 @@ dependencies = [
"prometheus",
"quick_cache",
"rand 0.9.5",
"rcgen",
"regex",
"reqwest 0.13.5",
"reqwest-middleware",
"reqwest-retry",
"rsa",
"rustls 0.23.35",
"rustls-native-certs 0.8.4",
"schemars 0.8.22",
"semver 1.0.28",
"serde",
@@ -15683,6 +15686,7 @@ dependencies = [
"tikv-jemalloc-ctl",
"tokio",
"tokio-postgres",
"tokio-rustls 0.26.5",
"tokio-stream",
"tokio-util",
"tonic 0.13.1",
@@ -15693,6 +15697,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"webpki-roots 1.0.9",
"windmill-macros",
"windmill-parser",
"windmill-parser-py",
+1
View File
@@ -757,4 +757,5 @@ hyper-http-proxy = { version = "1", default-features = false, features = ["rustl
hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
rustls-native-certs = "0.8"
webpki-roots = "1"
rcgen = "0.13"
+1 -1
View File
@@ -1 +1 @@
39f8896379295728a509c4d642574bb8173368e8
bf64ca98472a57cbfd150f8876877d2d4d9c217e
@@ -0,0 +1,6 @@
-- `created_by` tells this migration's row from one a user created by hand; a hub
-- sync updates in place and leaves it alone.
DELETE FROM resource_type
WHERE workspace_id = 'admins'
AND name = 'ai_instruction'
AND created_by = 'system';
@@ -0,0 +1,15 @@
-- Folder instructions for the AI chat: a markdown file resource whose
-- `value.content` applies to every item under the directory it lives in.
-- `format_extension` makes the resource editor render it as a plain .md file.
-- Seeded under 'admins' so every workspace sees it before the next hub sync.
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at)
VALUES (
'admins',
'ai_instruction',
'{"type": "object", "properties": {"content": {"type": "string"}}}',
'Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.',
'system',
'md',
now()
)
ON CONFLICT (workspace_id, name) DO NOTHING;
+76 -5
View File
@@ -1073,7 +1073,8 @@ fn no_auth_admin_authed() -> ApiAuthed {
}
/// Resolves OptJobAuthed from request parts.
/// Takes ownership of Parts and returns them back.
/// Takes ownership of Parts and returns them back, memoized into the extensions so a
/// second extraction in the same request is free.
#[allow(unreachable_code, unused_mut)]
pub async fn resolve_opt_job_authed(
mut parts: Parts,
@@ -1089,10 +1090,11 @@ pub async fn resolve_opt_job_authed(
));
}
let already_authed = parts.extensions.get::<OptJobAuthed>().cloned();
if let Some(authed) = already_authed {
return Ok((authed, parts));
// Safe only while what the route checks below read is fixed before the first
// extraction: path and method always are, `workspace_id` only while every
// `GatewayWorkspaceId` writer stays layered outside auth extraction.
if let Some(opt_job_authed) = parts.extensions.get::<OptJobAuthed>().cloned() {
return Ok((opt_job_authed, parts));
}
let already_tokened = parts.extensions.get::<Tokened>().cloned();
@@ -1185,6 +1187,9 @@ pub async fn resolve_opt_job_authed(
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
// Separate from the ApiAuthed insert above, which handlers taking
// `Extension<ApiAuthed>` read.
parts.extensions.insert(opt_job_authed.clone());
return Ok((opt_job_authed, parts));
}
}
@@ -1311,3 +1316,69 @@ pub async fn list_tokens_internal(
Ok(Json(tokens))
}
#[cfg(test)]
mod tests {
use super::*;
/// Dropping the `AUTH_CACHE` entry between the two resolutions is what makes a memo
/// hit observable: only a working memo can answer the second one.
#[tokio::test]
async fn resolve_opt_job_authed_is_memoized() {
// Never connected: an AUTH_CACHE hit resolves without a query, and AuthCache
// only needs the handle to exist. Without the timeout a regression — which
// falls through to a real lookup — spends sqlx's 30s default failing.
let db = sqlx::postgres::PgPoolOptions::new()
.acquire_timeout(std::time::Duration::from_millis(50))
.connect_lazy("postgres://memo@127.0.0.1:1/memo")
.expect("lazy pool");
let token = "memo_guard_token";
// Workspace-scoped: a job token on a workspace-less route is refused by
// check_job_token_for_global_route before the memo is ever taken.
let key = ("memo_workspace".to_string(), token.to_string());
let job_id = uuid::Uuid::from_u128(0x6d656d6f);
AUTH_CACHE.insert(
key.clone(),
ExpiringAuthCache {
authed: ApiAuthed {
email: "memo@windmill.dev".to_string(),
username: "memo".to_string(),
..Default::default()
},
expiry: chrono::Utc::now() + chrono::Duration::hours(1),
job_id: Some(job_id),
},
);
#[cfg(feature = "enterprise")]
let cache = AuthCache::new(db, None, None);
#[cfg(not(feature = "enterprise"))]
let cache = AuthCache::new(db, None);
let mut parts = http::Request::builder()
.uri("/api/w/memo_workspace/jobs/list")
.header(http::header::AUTHORIZATION, format!("Bearer {token}"))
.body(())
.unwrap()
.into_parts()
.0;
parts.extensions.insert(Arc::new(cache));
let (_, parts) = resolve_opt_job_authed(parts)
.await
.map_err(|(e, _)| e)
.expect("first resolution");
AUTH_CACHE.remove(&key);
let (second, _) = resolve_opt_job_authed(parts)
.await
.map_err(|(e, _)| e)
.expect("second resolution must hit the memo, not re-resolve");
assert_eq!(second.authed.username, "memo");
// The wrapper must survive whole: memoizing only the inner ApiAuthed and
// rebuilding around it drops the provenance forbid_elevated_job_token gates on.
assert_eq!(second.job_id, Some(job_id));
}
}
+1
View File
@@ -619,6 +619,7 @@ async fn edit_schedule(
dynamic_skip = $23,
email = $24,
edited_by = $25,
edited_at = now(),
permissioned_as = $26,
labels = COALESCE($27, labels)
WHERE path = $19 AND workspace_id = $20
+6 -1
View File
@@ -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 {
+59 -1
View File
@@ -281,8 +281,29 @@ enum ArchiveImpl {
Tar(tokio_tar::Builder<File>),
}
/// 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::*;
+6 -1
View File
@@ -18,7 +18,7 @@ parquet = []
aws_auth = ["dep:aws-sdk-sts", "dep:aws-config"]
otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk",
"dep:tracing-opentelemetry", "dep:opentelemetry-appender-tracing", "dep:tonic", "dep:opentelemetry"]
smtp = ["dep:mail-send"]
smtp = ["dep:mail-send", "dep:rustls", "dep:tokio-rustls", "dep:rustls-native-certs", "dep:webpki-roots"]
scoped_cache = []
cloud = []
dev_override = []
@@ -85,6 +85,10 @@ aws-sdk-rds = { workspace = true, optional = true }
indexmap.workspace = true
bytes.workspace = true
mail-send = { workspace = true, optional = true }
rustls = { workspace = true, optional = true }
tokio-rustls = { workspace = true, optional = true }
rustls-native-certs = { workspace = true, optional = true }
webpki-roots = { workspace = true, optional = true }
futures-core.workspace = true
async-stream.workspace = true
const_format.workspace = true
@@ -136,6 +140,7 @@ equivalent = "1.0.2"
[dev-dependencies]
# `test-util` is not part of tokio's `full`; it is what lets tests pause the clock.
tokio = { workspace = true, features = ["test-util"] }
rcgen.workspace = true
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemalloc-ctl = { optional = true, workspace = true }
+3
View File
@@ -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.
@@ -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.
+5 -1
View File
@@ -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<T: TriggerCrud>(
&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<T: TriggerCrud>(
&edit_trigger.base.path
)
})?;
if edit_trigger.base.path != path {
check_proper_path(&edit_trigger.base.path)?;
}
edit_trigger.error_handling.validate()?;
+2
View File
@@ -140,6 +140,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [
type: "object",
properties: {
relative_path: { type: "string", description: "Path to the codebase directory" },
extra_digest_paths: { type: "array", items: { type: "string" }, description: "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively" },
includes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to include in bundle" },
excludes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to exclude from bundle" },
format: { type: "string", enum: ["cjs", "esm"], description: "Bundle output format" },
@@ -159,6 +160,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [
example: [
"# codebases:",
'# - relative_path: ./shared # path to the codebase',
'# # extra_digest_paths: ["../packages/utils/src"] # imported paths outside relative_path, hashed recursively (skip node_modules/dist)',
'# includes: ["**/*.ts"] # files to include in bundle',
'# excludes: ["node_modules/**"] # files to exclude',
'# format: esm # bundle format: "cjs" or "esm"',
+16 -1
View File
@@ -61,7 +61,11 @@ import {
readConfigFile,
readEffectiveSyncBehavior,
} from "../../core/conf.ts";
import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts";
import {
SyncCodebase,
listSyncCodebases,
uncoveredBundleInputs,
} from "../../utils/codebase.ts";
import { pollJobWithQueueLogging } from "../../utils/job_polling.ts";
import fs from "node:fs";
import { createTarBlob, type TarEntry } from "../../utils/tar.ts";
@@ -446,8 +450,19 @@ export async function handleFile(
target: format == "cjs" ? "node20.15.1" : "esnext",
banner: codebase.banner,
// ...(codebase.banner != null && { banner: codebase.banner }),
metafile: true,
});
const endTime = performance.now();
const uncovered = uncoveredBundleInputs(
codebase,
path,
Object.keys(out.metafile?.inputs ?? {})
);
if (uncovered.length > 0) {
log.warnAlways(
`${path} bundles files outside codebase ${codebase.relative_path}; edits to them won't be detected as changes unless listed in extra_digest_paths: ${uncovered.join(", ")}`
);
}
bundleContent = out.outputFiles[0].text;
outputFiles = out.outputFiles ?? [];
if (outputFiles.length == 0) {
+1
View File
@@ -115,6 +115,7 @@ export interface SyncOptions {
export interface Codebase {
relative_path: string;
extra_digest_paths?: string[];
includes?: string[];
excludes?: string[];
assets?: {
+49 -4
View File
@@ -1,6 +1,44 @@
import { Codebase, SyncOptions } from "../core/conf.ts";
import * as log from "../core/log.ts";
import { digestDir } from "./utils.ts";
import { digestDir, generateHash, generateHashFromBuffer } from "./utils.ts";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
// A bare string is accepted, like `includes`/`excludes` in findCodebase.
function extraDigestPaths(codebase: Codebase): string[] {
return ([] as string[]).concat(codebase.extra_digest_paths ?? []);
}
async function digestPath(p: string): Promise<string> {
let s;
try {
s = await stat(p);
} catch {
throw new Error(
`Codebase extra_digest_paths entry not found: ${p} (resolved to ${path.resolve(p)})`
);
}
return s.isDirectory()
? await digestDir(p, "")
: await generateHashFromBuffer(await readFile(p));
}
/** Bundle inputs that no codebase digest covers, so edits to them would not trigger a re-push. */
export function uncoveredBundleInputs(
codebase: Codebase,
entry: string,
inputs: string[]
): string[] {
const entryAbs = path.resolve(entry);
const roots = [codebase.relative_path, ...extraDigestPaths(codebase)]
.map((r) => path.resolve(r));
return inputs.filter((i) => {
// non-file namespaces ("<define:x>", "(disabled):x", "ns:path") and installed packages
if (/^[<(]|^[a-z-]{2,}:/i.test(i) || i.includes("node_modules")) return false;
const abs = path.resolve(i);
return abs != entryAbs && !roots.some((r) => abs == r || abs.startsWith(r + path.sep));
});
}
export type SyncCodebase = Codebase & {
getDigest: (forceTar?: boolean) => Promise<string>;
@@ -23,13 +61,20 @@ export function listSyncCodebases(options: SyncOptions): SyncCodebase[] {
forceTar?: boolean
) => {
if (_digest == undefined) {
_digest = await digestDir(
// Concurrent callers read `_digest` across these awaits: assign it only once complete.
let digest = await digestDir(
codebase.relative_path,
JSON.stringify(codebase)
);
if (codebase.format == "esm") {
_digest += ".esm";
const extra = extraDigestPaths(codebase);
if (extra.length > 0) {
const hashes = await Promise.all(extra.map(digestPath));
digest = await generateHash(digest + hashes.join(""));
}
if (codebase.format == "esm") {
digest += ".esm";
}
_digest = digest;
if (!alreadyPrinted) {
alreadyPrinted = true;
log.info(`Codebase ${codebase.relative_path}, digest: ${_digest}`);
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
listSyncCodebases,
uncoveredBundleInputs,
} from "../src/utils/codebase.ts";
test("extra_digest_paths edits change the codebase digest", async () => {
const root = await mkdtemp(join(tmpdir(), "wmill-codebase-"));
await mkdir(join(root, "f"));
await mkdir(join(root, "packages/utils"), { recursive: true });
await writeFile(join(root, "f/main.ts"), "export * from '../packages/utils/a'");
await writeFile(join(root, "packages/utils/a.ts"), "export const a = 1");
const digest = () =>
listSyncCodebases({
codebases: [
{
relative_path: join(root, "f"),
extra_digest_paths: [join(root, "packages/utils")],
},
],
} as any)[0].getDigest();
try {
const before = await digest();
await writeFile(join(root, "packages/utils/a.ts"), "export const a = 2");
expect(await digest()).not.toBe(before);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("uncoveredBundleInputs reports only files outside the digested roots", () => {
const codebase = { relative_path: "f", extra_digest_paths: ["packages/a"] };
expect(
uncoveredBundleInputs(codebase, "g/entry.ts", [
"g/entry.ts",
"f/main.ts",
"packages/a/x.ts",
"packages/ab/y.ts",
"node_modules/pg/index.js",
"<define:process.env>",
])
).toEqual(["packages/ab/y.ts"]);
});
+7
View File
@@ -147,6 +147,13 @@
"type": "string",
"description": "Path to the codebase directory"
},
"extra_digest_paths": {
"type": "array",
"items": {
"type": "string"
},
"description": "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively"
},
"includes": {
"type": "array",
"items": {
+6
View File
@@ -46,6 +46,12 @@ Symbols, not line numbers, are cited: they drift less.
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
token stored as a secret, never `$WM_TOKEN`. Token scopes cannot narrow superadmin routes.
- **Write access to a folder steers its readers' AI chat.** An `ai_instruction` resource's body is
delivered to the global chat of everyone who can read the resource, with no opt-in, whenever the
chat touches a path under its folder (`copilot/chat/folderInstructions.ts`); the chat then acts
with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play
for every reader until turned off. The resource ACL is the only control, so granting write on a
folder grants that influence.
- **A remote deploy token is a credential for another instance**, held per account and workspace
(`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`).
Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or
@@ -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}
<div class="mt-2">
<WebSearchSourcesDisplay sources={answer.sources} />
</div>
{/if}
</div>
<!-- What the run cost, as a footnote to what it produced. -->
+42 -24
View File
@@ -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<number>()
// A search opens on its sources, so for it the reader's toggle records a close.
let collapsedSearches = new SvelteSet<number>()
// 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<AgentTraceEntry, { kind: 'tool' }>): string {
const job = jobOf(entry.jobId)
const duration = job?.['duration_ms']
return duration === undefined
? entry.name
: `${entry.name} · ${msToReadableTimeShort(duration)}`
function toolLabel(
entry: Extract<AgentTraceEntry, { kind: 'tool' }>,
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(' · ')
}
</script>
@@ -81,25 +91,29 @@
{#if entry.kind === 'assistant'}
<div class="min-w-0">
<GfmMarkdown md={entry.content} noPadding />
{#if entry.sources}
<div class="mt-2">
<WebSearchSourcesDisplay sources={entry.sources} />
</div>
{/if}
</div>
{:else if entry.kind === 'search'}
<!-- Nothing to reveal: the worker records only that a search ran, and its
citations render under the assistant turn that follows. -->
<!-- Open on its sources, as the session chat shows a search. A tool that returns the
same shape stays closed like every other tool row: the agent may call it twenty
times, and the reader came for the run, not for its bibliography. -->
<ChatCollapsibleCard
label="Web search"
expanded={false}
toggleable={false}
onToggle={() => {}}
/>
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}
<WebSearchSourcesDisplay sources={entry.sources} />
{/if}
</ChatCollapsibleCard>
{:else}
{@const job = jobOf(entry.jobId)}
{@const failed = job?.type === 'CompletedJob' && !job.success}
{@const search = webSearchResultOf(entry.result)}
<ChatCollapsibleCard
label={toolLabel(entry)}
label={toolLabel(entry, search)}
expanded={expanded.has(index)}
onToggle={() => toggle(index, entry)}
contentClass="space-y-3"
@@ -110,11 +124,15 @@
{#if job?.logs}
<ToolContentDisplay title="Logs" content={job.logs} />
{/if}
<ToolContentDisplay
title="Result"
content={entry.result}
error={job?.type === 'CompletedJob' && !job.success ? entry.result : undefined}
/>
{#if search && !failed}
<WebSearchSourcesDisplay sources={search.sources} favicons={false} />
{:else}
<ToolContentDisplay
title="Result"
content={entry.result}
error={failed ? entry.result : undefined}
/>
{/if}
{#if entry.resourcePath}
<div class="text-2xs text-hint flex items-center gap-1">
<Globe size={11} />
@@ -1,7 +1,6 @@
<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -10,6 +9,9 @@
import autosize from '$lib/autosize'
import Label from './Label.svelte'
import TextInput from './text_input/TextInput.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const dispatch = createEventDispatcher()
@@ -35,7 +37,7 @@
async function updateVariable(): Promise<void> {
await WorkspaceService.setEnvironmentVariable({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
requestBody: {
value: value,
name: name
@@ -6,7 +6,7 @@
collectTransformRefs
} from './deployDependencies'
import { base } from '$lib/base'
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
import { enterpriseLicense, superadmin } from '$lib/stores'
import {
AppService,
FlowService,
@@ -50,6 +50,9 @@
type OnBehalfOfDetails
} from './OnBehalfOfSelector.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const dispatch = createEventDispatcher()
@@ -209,7 +212,12 @@
let source: string | undefined
let targetValue: string | undefined
try {
source = await getOnBehalfOf(dep.kind, dep.path, $workspaceStore!, additionalInformation)
source = await getOnBehalfOf(
dep.kind,
dep.path,
$operatingWorkspace!,
additionalInformation
)
} catch {}
try {
targetValue = await getOnBehalfOf(dep.kind, dep.path, target!, additionalInformation)
@@ -226,7 +234,10 @@
): Promise<{ kind: Kind; path: string }[]> {
async function rec(kind: Kind, path: string): Promise<{ kind: Kind; path: string }[]> {
if (kind == 'schedule') {
const schedule = await ScheduleService.getSchedule({ workspace: $workspaceStore!, path })
const schedule = await ScheduleService.getSchedule({
workspace: $operatingWorkspace!,
path
})
if (schedule.script_path && schedule.script_path != '') {
if (schedule.script_path) {
return [{ kind: 'script', path: schedule.script_path }]
@@ -237,7 +248,7 @@
return []
}
} else if (kind == 'flow') {
const flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path })
const flow = await FlowService.getFlowByPath({ workspace: $operatingWorkspace!, path })
return getAllModules(flow.value.modules, flow.value.failure_module).flatMap((x) => {
let result: { kind: Kind; path: string }[] = []
if (x.value.type == 'script' || x.value.type == 'rawscript' || x.value.type == 'flow') {
@@ -257,7 +268,7 @@
return result
})
} else if (kind == 'app') {
const app = await AppService.getAppByPath({ workspace: $workspaceStore!, path })
const app = await AppService.getAppByPath({ workspace: $operatingWorkspace!, path })
let result: { kind: Kind; path: string }[] = []
if (app.raw_app) {
const rawAppValue = app.value as { runnables?: Record<string, Runnable> }
@@ -285,7 +296,7 @@
}
return result
} else if (kind == 'resource') {
const res = await ResourceService.getResource({ workspace: $workspaceStore!, path })
const res = await ResourceService.getResource({ workspace: $operatingWorkspace!, path })
function recObj(obj: any): { kind: Kind; path: string }[] {
if (typeof obj == 'string') {
if (obj.startsWith('$var:')) {
@@ -310,12 +321,16 @@
]
} else if (kind == 'trigger') {
if (additionalInformation?.triggers) {
return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!)
return getTriggerDependency(
additionalInformation.triggers.kind,
path,
$operatingWorkspace!
)
}
throw new Error('Missing trigger information')
} else if (kind == 'script') {
const imports = await WorkspaceService.getImports({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
importerPath: path
})
return imports.map((importedPath) => ({ kind: 'script' as Kind, path: importedPath }))
@@ -363,7 +378,7 @@
const result = await deployItem({
kind,
path,
workspaceFrom: $workspaceStore!,
workspaceFrom: $operatingWorkspace!,
workspaceTo: target!,
additionalInformation,
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind),
@@ -398,7 +413,7 @@
diffDrawer?.openDrawer()
let values = await Promise.all([
getItemValue(kind, path, workspaceToDeployTo!, additionalInformation),
getItemValue(kind, path, $workspaceStore!, additionalInformation)
getItemValue(kind, path, $operatingWorkspace!, additionalInformation)
])
diffDrawer?.setDiff({
mode: 'simple',
@@ -409,12 +424,12 @@
}
async function loadDestinations() {
const workspace = $workspaceStore!
const workspace = $operatingWorkspace!
const [deployTo, remote] = await Promise.all([
WorkspaceService.getDeployTo({ workspace }),
WorkspaceService.getRemoteDeployTarget({ workspace }).catch(() => undefined)
])
if (workspace !== $workspaceStore) return
if (workspace !== $operatingWorkspace) return
parentWorkspace = deployTo.deploy_to
remoteStatus = remote
// Keep the user's pick across a reload, but only while this workspace still has it: the
@@ -430,7 +445,7 @@
}
$effect(() => {
$workspaceStore && untrack(() => loadDestinations())
$operatingWorkspace && untrack(() => loadDestinations())
})
// Until the caller has a token for the remote instance there is nothing to deploy against, so
@@ -439,7 +454,7 @@
destination === 'parent'
? parentWorkspace
: isRemote && remoteStatus?.connection
? remoteDeployWorkspace($workspaceStore!, remoteStatus.connection)
? remoteDeployWorkspace($operatingWorkspace!, remoteStatus.connection)
: undefined
)
let destinationLabel = $derived(
@@ -560,12 +575,12 @@
{#if isRemote && remoteTarget}
<div class="mt-3">
<RemoteDeployConnect
workspace={$workspaceStore!}
workspace={$operatingWorkspace!}
target={remoteTarget}
connection={remoteStatus?.connection}
returnTo={kind === 'trigger'
? undefined
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($workspaceStore!)}`}
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($operatingWorkspace!)}`}
onChange={loadDestinations}
/>
</div>
@@ -1,6 +1,7 @@
<script lang="ts" module>
import { z } from 'zod'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { useHostedPage } from '$lib/components/hostedPage'
import { formatDatePretty, parsePrettyDate, type IconType } from '$lib/utils'
export type FilterSchemaRec = Record<string, FilterSchema>
@@ -134,8 +135,9 @@
// URL lacks, so a value written to the instance is undone on the next sync. Going through
// urlFilter rather than straight to history keeps the search-param cells in step, so it
// does not matter whether a popstate follows.
const hosted = useHostedPage()
function seed(values: Partial<FilterInstanceRec<T>>) {
const sp = new URLSearchParams(window.location.search)
const sp = new URLSearchParams(hosted ? untrack(() => hosted.search) : window.location.search)
for (const [key, value] of Object.entries(values) as [string, unknown][]) {
if (value === undefined || value === null || sp.has(key)) continue
urlFilter[key] = value instanceof Date ? value.toISOString() : value
@@ -1,7 +1,7 @@
<script lang="ts">
import { Badge, Button } from './common'
import Popover from './meltComponents/Popover.svelte'
import { Play, RefreshCw } from 'lucide-svelte'
import { ChevronDown, Play, RefreshCw } from 'lucide-svelte'
import { FlowService, JobService, type FlowVersion } from '$lib/gen'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -20,6 +20,8 @@
enterpriseOnly?: boolean
variant?: 'default' | 'accent'
unifiedSize?: 'xs' | 'sm' | 'md' | 'lg'
/** `link` renders the trigger as inline text, for use inside a sentence. */
triggerStyle?: 'button' | 'link'
/**
* For nested-step restarts: path of ancestor containers from the top-level
* step down to the leaf. When provided, the LAST entry's step_id is the
@@ -71,6 +73,7 @@
enterpriseOnly = false,
variant = 'default',
unifiedSize = 'md',
triggerStyle = 'button',
nestedPath = undefined,
nestedTopStepId = undefined,
nestedTopBranchOrIterationN = undefined,
@@ -89,6 +92,10 @@
: selectedJobStep
)
const restartTitle = $derived(
`Re-start this flow from step ${displayStepId} (included).${enterpriseOnly ? ' This is a feature only available in enterprise edition.' : ''}`
)
// Sentinel value meaning "use the same version as the original run" (backend receives undefined)
const RUN_VERSION_SENTINEL = -1
@@ -142,8 +149,13 @@
})
return out
})
// A link has no button of its own to restart on click: it always opens the
// popover, whose Restart button then acts as the confirmation.
const needsPopup = $derived(
!!flowPath || iterationFields.length > 0 || selectedJobStepType !== 'single'
triggerStyle === 'link' ||
!!flowPath ||
iterationFields.length > 0 ||
selectedJobStepType !== 'single'
)
let selectedFlowVersion: number = $state(RUN_VERSION_SENTINEL)
let flowVersions: Array<FlowVersion> = $state([])
@@ -244,12 +256,21 @@
</label>
{/snippet}
{#snippet restartTriggerButton(usePlayIcon: boolean)}
{#if triggerStyle === 'link'}
<!-- The popover trigger is already a <button>; nesting another is invalid. -->
<span class="underline hover:no-underline" title={restartTitle}>Restart from this step</span>
{:else}
{@render restartButton(usePlayIcon)}
{/if}
{/snippet}
{#snippet restartButton(usePlayIcon: boolean)}
<Button
title={`Re-start this flow from step ${displayStepId} (included).${enterpriseOnly ? ' This is a feature only available in enterprise edition.' : ''}`}
title={restartTitle}
{variant}
{unifiedSize}
{disabled}
startIcon={{ icon: usePlayIcon ? Play : RefreshCw }}
endIcon={needsPopup ? { icon: ChevronDown } : undefined}
nonCaptureEvent={!usePlayIcon || !!flowPath}
onClick={() => {
if (usePlayIcon && !flowPath) {
@@ -273,6 +294,12 @@
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-start' }}
disablePopup={!needsPopup}
triggerAttrs={{
'aria-label':
triggerStyle === 'link'
? `Restart from this step (${displayStepId})`
: `Re-start from ${displayStepId}`
}}
on:openChange={(e) => {
if (e.detail) {
loadFlowVersions()
@@ -1,8 +1,11 @@
<script lang="ts">
import type { SyncResponse } from '$lib/git-sync'
let { previewResult } = $props<{
let { previewResult, heightClass = 'max-h-40' } = $props<{
previewResult: SyncResponse | undefined
/** Room the list gets before it scrolls. A host with a whole step to spare hands it
* `flex-1 min-h-0` so it takes what is left. */
heightClass?: string
}>()
let added = $derived(
@@ -16,7 +19,7 @@
)
</script>
<div class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary">
<div class="border rounded p-2 text-xs {heightClass} overflow-y-auto bg-surface-secondary">
<div class="font-semibold text-[11px] mb-1 text-primary">Preview of changes:</div>
{#if !added.length && !deleted.length && !edited.length}
+7 -28
View File
@@ -4,7 +4,6 @@
type AiAgent,
type FlowModule,
type InputTransform,
type JavascriptTransform,
type Job
} from '$lib/gen'
import { getScriptByPath } from '$lib/scripts'
@@ -20,7 +19,7 @@
type LinkedAgentDraft
} from './flows/linkedAgentDrafts'
import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils'
import { AGENT_HISTORY_KEYS } from './flows/agentFormFields'
import { agentTestInputTransforms } from './flows/agentFormFields'
import { sendUserToast } from '$lib/toast'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -175,37 +174,17 @@
// `args` spans the whole AI agent schema, so on a linked step it carries every brain key as
// undefined; overlaying those would shadow the draft's brain, so an inlined step takes only
// the inputs its form offers. A blank history input is unset, as on the step: an expression
// evaluating to nothing reads as an empty memory id, and the step's transform is stale.
const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length)
const formKeys = (
draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args)
).filter(
(key) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
)
const stepTransforms = Object.fromEntries(
Object.entries((agentVal.input_transforms ?? {}) as Record<string, InputTransform>).filter(
([key]) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
)
)
// the inputs its form offers.
// The test form only covers the schema it was given, and for a standalone agent that may be
// the flow-local one (the agent editor shows the brain in its own form, not here). Take the
// brain from the module as authored and let the form's own keys win over it, so an edit made
// in the form after the test panel mounted is what runs. A linked agent needs none of this:
// the server reads its brain from the resource.
const inputTransforms: { [key: string]: JavascriptTransform | InputTransform } = {
...(agentVal.agent ? {} : stepTransforms),
...Object.fromEntries(
formKeys.map((key) => [
key,
{
expr: `flow_input.${key}`,
type: 'javascript'
}
])
)
}
const inputTransforms = agentTestInputTransforms(
agentVal.agent ? {} : ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>),
args,
draft ? AGENT_FLOW_LOCAL_KEYS : Object.keys(args)
)
await jobLoader?.runFlowPreview(
args,
@@ -4,27 +4,55 @@
* workspace and the admin bypass in its popover. Renders nothing for operators, who have no edit
* affordance for it to explain.
*/
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { resource } from 'runed'
import {
canUserBypassRuleKind,
getActiveRulesetsForKind,
isRuleActive
canUserBypassRuleKindInRulesets,
fetchProtectionRulesForWorkspace,
getActiveRulesetsForKindInRulesets,
isRuleActiveInRulesets,
protectionRulesState
} from '$lib/workspaceProtectionRules.svelte'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { canCreateFork } from '$lib/utils/editInFork'
import { switchWorkspace } from '$lib/storeUtils'
import { Badge, Button } from './common'
import Popover from './meltComponents/Popover.svelte'
import Toggle from './Toggle.svelte'
import { GitFork, Lock, ShieldOff } from 'lucide-svelte'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
let activeDeployRulesets = $derived(getActiveRulesetsForKind('DisableDirectDeployment'))
let canBypass = $derived(canUserBypassRuleKind('DisableDirectDeployment', $userStore))
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
// The shared rules state holds the navigation workspace's rules, so a host acting on another
// workspace (an AI session's fork) has to ask for that one's.
const foreignRules = resource(
() => ($operatingWorkspace !== $workspaceStore ? $operatingWorkspace : undefined),
(workspace) => (workspace ? fetchProtectionRulesForWorkspace(workspace) : Promise.resolve([]))
)
let rulesets = $derived(
$operatingWorkspace === $workspaceStore
? (protectionRulesState.rulesets ?? [])
: (foreignRules.current ?? [])
)
let activeDeployRulesets = $derived(
getActiveRulesetsForKindInRulesets(rulesets, 'DisableDirectDeployment')
)
let canBypass = $derived(
canUserBypassRuleKindInRulesets(rulesets, 'DisableDirectDeployment', operatingUser.current)
)
let canonicalDev = $derived(findCanonicalDevWorkspace($operatingWorkspace, $userWorkspaces))
// Forking may itself be blocked by DisableWorkspaceForking, so only suggest it
// when the user can actually fork this workspace.
let canFork = $derived(canCreateFork($userStore))
let canFork = $derived(
!isRuleActiveInRulesets(rulesets, 'DisableWorkspaceForking') ||
canUserBypassRuleKindInRulesets(rulesets, 'DisableWorkspaceForking', operatingUser.current)
)
let editAdvice = $derived(
canFork
? 'You will need to either fork the workspace, or make your changes locally and submit a PR to an authorized user.'
@@ -34,7 +62,9 @@
// The toggle is only offered to a user who can bypass, but the answer can change under a
// workspace switch, so the checked flag alone never grants the edit.
let bypassActive = $derived(canBypass && overrideChecked)
let canEdit = $derived(!isRuleActive('DisableDirectDeployment') || bypassActive)
let canEdit = $derived(
!isRuleActiveInRulesets(rulesets, 'DisableDirectDeployment') || bypassActive
)
let {
onUpdateCanEditStatus = (value) => {}
@@ -55,7 +85,7 @@
)
</script>
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
<div class="my-2">
<Popover
placement="bottom-start"
@@ -12,7 +12,6 @@
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
pageDrawerSessionSource,
setPageDrawerAnchor
} from './sessions/pageDrawerSession'
@@ -102,7 +101,6 @@
* dedicated editor elsewhere: the generic form would render its configuration field by field,
* and materialize a default into every one the value leaves out. */
export async function initEdit(p: string, opts?: { json?: boolean }): Promise<void> {
if (handOffPageDrawer(RESOURCES_PATH, p)) return
// A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag
// would still be standing when the next drawer session ends and would swallow that one's
// anchor clear. Every session starts having to clear its own.
+24 -15
View File
@@ -10,7 +10,7 @@
} from '$lib/gen'
import type { Folder } from '$lib/gen/types.gen'
import { createEventDispatcher } from 'svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { Alert, Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { sendUserToast } from '$lib/toast'
@@ -22,11 +22,22 @@
import Toggle from './Toggle.svelte'
import { Trash } from 'lucide-svelte'
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
import {
useOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const dispatch = createEventDispatcher()
let restricted = $derived(
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
isDemoWorkspaceRestricted(
$operatingWorkspace,
operatingUser.current?.is_admin,
operatingUser.current?.is_super_admin
)
)
type Kind =
@@ -81,7 +92,7 @@
defaultPermsLabel = `Folder f/${folderName} permissions`
try {
const folder: Folder = await FolderService.getFolder({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
name: folderName
})
if (path !== currentPath) return
@@ -134,7 +145,7 @@
const currentPath = path
try {
const resource = await ResourceService.getResource({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path: currentPath
})
if (path !== currentPath) return
@@ -164,27 +175,27 @@
}
async function loadOwner() {
own = isOwner(path, $userStore!, $workspaceStore!)
own = isOwner(path, operatingUser.current!, $operatingWorkspace!)
}
async function loadAcls() {
acls = Object.entries(
await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind })
await GranularAclService.getGranularAcls({ workspace: $operatingWorkspace!, path, kind })
)
}
async function loadGroups(): Promise<void> {
groups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
groups = await GroupService.listGroupNames({ workspace: $operatingWorkspace! })
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
}
async function deleteAcl(owner: string) {
try {
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path,
kind,
requestBody: { owner }
@@ -193,7 +204,7 @@
for (const varPath of linkedVarPaths) {
try {
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path: varPath,
kind: 'variable',
requestBody: { owner }
@@ -212,7 +223,7 @@
async function addAcl(owner: string, write: boolean) {
await GranularAclService.addGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path,
kind,
requestBody: { owner, write }
@@ -221,7 +232,7 @@
for (const varPath of linkedVarPaths) {
try {
await GranularAclService.addGranularAcls({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path: varPath,
kind: 'variable',
requestBody: { owner, write }
@@ -251,9 +262,7 @@
</div>
{/if}
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-emphasis"
>Extra members ({acls?.length ?? 0})</span
>
<span class="text-sm font-semibold text-emphasis">Extra members ({acls?.length ?? 0})</span>
{#if linkedVarPaths.length > 0}
<div class="flex flex-col gap-1.5 p-3 border rounded bg-surface-secondary text-xs">
<Toggle
@@ -12,10 +12,13 @@
import autosize from '$lib/autosize'
import { ResourceService, VariableService } from '$lib/gen'
import { oauthStore, workspaceStore } from '$lib/stores'
import { oauthStore } from '$lib/stores'
import Password from './Password.svelte'
import { createEventDispatcher } from 'svelte'
import HighlightTheme from './HighlightTheme.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
let drawer: Drawer | undefined = $state()
let token: undefined | string = $state(undefined)
@@ -78,7 +81,7 @@
async function save() {
if (!path) return
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
requestBody: {
path,
value: password,
@@ -89,7 +92,7 @@
})
await ResourceService.createResource({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
requestBody: {
resource_type: 'postgresql',
path,
+11 -5
View File
@@ -93,6 +93,8 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Keeps the sr-only checkbox's absolute box on the toggle; it does not size the knob,
which resolves against the track. -->
<div
class="relative"
onclick={stopPropagation(bubble('click'))}
@@ -116,21 +118,25 @@
dispatch('change', !!checked)
})}
/>
<!-- Knob offsets resolve against the track's padding box, so the track stays
relative. Whole px and 2px of inset: at 1x a track on a fractional x blurs its
edge over a whole pixel, which swallows a thinner gap.
knob = h - 6, checked translate = w - h. -->
<div
class={classNames(
"transition-all bg-surface-sunken rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
"relative transition-all bg-surface-sunken rounded-full peer peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
color == 'red'
? 'peer-checked:bg-red-600'
: color == 'blue'
? 'peer-checked:bg-blue-400 '
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-900',
size === 'md'
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
? 'w-[44px] h-[24px] after:h-[18px] after:w-[18px] peer-checked:after:translate-x-[20px]'
: size === 'sm'
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
? 'w-[36px] h-[20px] after:h-[14px] after:w-[14px] peer-checked:after:translate-x-[16px]'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3',
? 'w-[20px] h-[12px] after:h-[6px] after:w-[6px] peer-checked:after:translate-x-[8px]'
: 'w-[28px] h-[16px] after:h-[10px] after:w-[10px] peer-checked:after:translate-x-[12px]',
inputBorderClass()
)}
></div>
@@ -7,7 +7,6 @@
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
handOffPageDrawer,
pageDrawerSessionSource,
setPageDrawerAnchor
} from './sessions/pageDrawerSession'
@@ -389,7 +388,6 @@
}
export function editVariable(edit_path: string): void {
if (handOffPageDrawer(VARIABLES_PATH, edit_path)) return
reset()
editPath = edit_path
selected = curWs!
+15 -14
View File
@@ -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)
})
})
+18 -18
View File
@@ -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
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import type { ICellRendererParams } from 'ag-grid-community'
import { defaultCellRenderer } from './utils'
function render(value: unknown) {
const renderer = defaultCellRenderer('link')!
return renderer({ value } as ICellRendererParams) as HTMLElement
}
// AG Grid writes a renderer's string output to innerHTML, and link cells hold run results.
describe('link cell renderer', () => {
it('renders a link without parsing its label or href as markup', () => {
const el = render({ href: 'https://x.test/"><img src=x onerror=alert(1)>', label: '<b>hi</b>' })
expect(el.tagName).toBe('A')
expect(el.textContent).toBe('<b>hi</b>')
expect(el.getAttribute('href')).toBe('https://x.test/"><img src=x onerror=alert(1)>')
expect(el.children).toHaveLength(0)
})
it.each([
{ href: 'javascript:alert(1)', label: 'click' },
'javascript:alert(1)',
{ href: 'data:text/html,<script>alert(1)</script>', label: 'click' }
])('renders a non-navigating href %j as plain text', (value) => {
const el = render(value)
expect(el.tagName).toBe('SPAN')
expect(el.hasAttribute('href')).toBe(false)
})
})
@@ -8,6 +8,7 @@ import type { TableAction } from '$lib/components/apps/editor/component'
import { mount, unmount } from 'svelte'
import { Button } from '$lib/components/common'
import { Trash2 } from 'lucide-svelte'
import { safeHref } from '$lib/utils/safeHref'
export type WindmillColumnDef = ColDef & {
_isActionsColumn?: boolean
@@ -110,15 +111,25 @@ export function isLinkObject(value: any): value is LinkObject {
export function defaultCellRenderer(cellRendererType: string) {
if (cellRendererType === 'link') {
// AG Grid assigns a string returned by a cell renderer to innerHTML, and cell values are
// run results, so the link is built as a DOM node that never parses them as markup.
return (params: ICellRendererParams) => {
if (isLinkObject(params.value)) {
const value = params.value
return `<a href=${value.href} class="underline" target="_blank">${value.label}</a>`
} else if (params.value) {
return `<a href=${params.value} class="underline" target="_blank">${params.value}</a>`
} else {
if (!params.value) {
return params.value
}
const { href, label } = isLinkObject(params.value)
? params.value
: { href: params.value, label: params.value }
const allowedHref = safeHref(String(href), window.location.href)
const el = document.createElement(allowedHref ? 'a' : 'span')
el.textContent = String(label)
if (allowedHref) {
el.setAttribute('href', allowedHref)
el.setAttribute('target', '_blank')
el.setAttribute('rel', 'noopener noreferrer')
el.className = 'underline'
}
return el
}
} else {
return undefined
@@ -42,7 +42,7 @@
{@render middle?.()}
{/if}
</div>
<div class="flex flex-wrap items-center gap-2 lg:gap-4">
<div class="flex flex-wrap items-center gap-2">
{#if right}
{@render right?.()}
{/if}
@@ -71,6 +71,16 @@
}
}
// The entry follows the prop for as long as the overlay is open: a dialog opened before
// the AI chat still has to rise above it when the chat is opened underneath it.
$effect(() => {
const wanted = open && minZIndex > 0 ? minZIndex : undefined
untrack(() => {
if (wanted !== undefined) minZIndexEntries[id] = wanted
else delete minZIndexEntries[id]
})
})
// A disposable can be unmounted while still open, by an ancestor that tears its whole
// subtree down. Its id would then sit on the stack forever, and since the topmost entry
// arbitrates Escape, every overlay opened afterwards would stop answering it.
@@ -84,10 +94,10 @@
offset = initialOffset
if (stack.val.includes(id)) {
stack.val = stack.val.filter((drawer) => drawer !== id)
if (minZIndex > 0) {
delete minZIndexEntries[id]
}
}
// Unconditionally: `minZIndex` may have fallen to 0 since the entry was written (the
// chat closed), and a leftover entry keeps every later overlay elevated for good.
delete minZIndexEntries[id]
}
export function isOpen() {
@@ -10,6 +10,9 @@
import { fade } from 'svelte/transition'
import { zIndexes } from '$lib/zIndexes'
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
import Disposable from '../drawer/Disposable.svelte'
import { overlayHostActive, setTopmostSurface } from '../overlayHost.svelte'
import { untrack } from 'svelte'
interface Props {
title: string
@@ -30,6 +33,9 @@
* window, so a stacked pair would both close on one press; set it
* false on the underlying modal while its child is up. */
closeOnEscape?: boolean
/** Blocks every way out — the header ✕, Escape and an outside click — for a dialog
* holding work in flight that a dismissal would orphan. */
preventDismiss?: boolean
/** Wider side padding and a lighter title, for a dialog whose body is a form rather
* than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */
formStyling?: boolean
@@ -51,10 +57,11 @@
contentClasses = '',
closeOnOutsideClick = true,
closeOnEscape = true,
preventDismiss = false,
formStyling = false,
headerLeft,
headerRight,
children
children: body
}: Props = $props()
const widthMap = {
@@ -84,8 +91,30 @@
isOpen = true
}
// Registered in the disposable stack so a drawer opened from inside the dialog (a
// resource picker's editor) stacks above it, and so Escape and outside clicks aimed
// at that drawer do not close the dialog underneath.
let disposable: Disposable | undefined = $state(undefined)
const hostActive = overlayHostActive()
setTopmostSurface(() => disposable?.isTopmost() ?? true)
$effect(() => {
isOpen
untrack(() => (isOpen ? disposable?.openDrawer() : disposable?.closeDrawer()))
})
/** Whether this dialog was the overlay on top when the interaction started. `clickOutside`
* awaits its async `exclude` before reporting, and an overlay above closes itself in that
* gap — by the time the report lands, the dialog looks topmost and would close with it. */
let topmostAtPress = true
function snapshotTopmost() {
topmostAtPress = disposable?.isTopmost() ?? true
}
function handleKeyDown(event: KeyboardEvent) {
if (!isOpen || !closeOnEscape) return
if (!isOpen || !closeOnEscape || preventDismiss) return
// Hidden hosts stay mounted and still receive window keys — see overlayHost.
if (!hostActive()) return
if (!(disposable?.isTopmost() ?? true)) return
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
@@ -97,79 +126,94 @@
return fade(node, { duration: 200 })
}
// Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so
// the dialog isn't hidden behind it; otherwise keep the default modal
// stacking just above disposables (zIndexes.disposables).
const overlayZIndex = $derived(
chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10
)
// Elevate above the AI chat panel while chat is open so the dialog isn't hidden behind it.
const minZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : 0)
</script>
<svelte:window onkeydown={handleKeyDown} />
<svelte:window
onkeydown={handleKeyDown}
onpointerdowncapture={snapshotTopmost}
onclickcapture={(e) => {
// A keyboard-activated control emits a click with no pointer event before it (`detail`
// 0), so the pointerdown snapshot would be whatever a previous interaction left.
if (e.detail === 0) snapshotTopmost()
}}
/>
{#if isOpen}
<Portal name="always-mounted" {target}>
<div
class={'fixed top-0 bottom-0 left-0 right-0 transition-all overflow-auto bg-black bg-opacity-60 w-full h-full'}
style="z-index: {overlayZIndex}"
transition:fadeFast|local
>
<div class="flex min-h-full items-center justify-center p-8">
<Disposable bind:open={isOpen} bind:this={disposable} preventEscape {minZIndex}>
{#snippet children({ zIndex })}
{#if isOpen}
<Portal name="always-mounted" {target}>
<div
style={`width: ${widthMap[fixedWidth]}; ${
heightMap[fixedHeight] ? `height: ${heightMap[fixedHeight]}; ` : ''
}${css?.popup?.style || ''}`}
class={twMerge(
'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface',
formStyling ? 'py-4 px-6' : 'p-4',
css?.popup?.class,
'wm-modal-form-popup'
)}
use:clickOutside={{
onClickOutside: () => closeOnOutsideClick && close()
}}
class={'fixed top-0 bottom-0 left-0 right-0 transition-all overflow-auto bg-black bg-opacity-60 w-full h-full'}
style="z-index: {zIndex}"
transition:fadeFast|local
>
<List gap="md">
<div class="flex w-full">
<List horizontal justify="between">
<h3 class={formStyling ? 'font-semibold' : undefined}>{title}</h3>
<div class="grow w-min-0">
<div class="flex min-h-full items-center justify-center p-8">
<div
style={`width: ${widthMap[fixedWidth]}; ${
heightMap[fixedHeight] ? `height: ${heightMap[fixedHeight]}; ` : ''
}${css?.popup?.style || ''}`}
class={twMerge(
'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface',
formStyling ? 'py-4 px-6' : 'p-4',
css?.popup?.class,
'wm-modal-form-popup'
)}
use:clickOutside={{
onClickOutside: () =>
closeOnOutsideClick && !preventDismiss && topmostAtPress && close(),
// A dropdown opened from inside the dialog portals its menu out of it, so a
// click on one of its items lands outside this node and would close the
// dialog under the menu.
exclude: async () =>
Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[]
}}
>
<List gap="md">
<div class="flex w-full">
<List horizontal justify="between">
<div class="min-w-0 grow">
{@render headerLeft?.()}
</div>
<div class="min-w-0 grow-0 justify-end">
<List horizontal justify="end">
{@render headerRight?.()}
<div class="w-8">
<button
id="modal-close-button"
onclick={() => {
close()
}}
class="hover:bg-surface-hover rounded-full w-8 h-8 flex items-center justify-center transition-all"
>
<X class="text-primary " />
</button>
<h3 class={formStyling ? 'font-semibold' : undefined}>{title}</h3>
<div class="grow w-min-0">
<List horizontal justify="between">
<div class="min-w-0 grow">
{@render headerLeft?.()}
</div>
<div class="min-w-0 grow-0 justify-end">
<List horizontal justify="end">
{@render headerRight?.()}
<div class="w-8">
<button
id="modal-close-button"
disabled={preventDismiss}
onclick={() => {
close()
}}
class="hover:bg-surface-hover rounded-full w-8 h-8 flex items-center justify-center transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<X class="text-primary " />
</button>
</div>
</List>
</div>
</List>
</div>
</List>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full flex grow min-h-0 {contentClasses}"
onclick={stopPropagation(() => {})}
>
{@render body?.()}
</div>
</List>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full flex grow min-h-0 {contentClasses}"
onclick={stopPropagation(() => {})}
>
{@render children?.()}
</div>
</List>
</div>
</div>
</div>
</div>
</Portal>
{/if}
</Portal>
{/if}
{/snippet}
</Disposable>
@@ -12,6 +12,9 @@
allowStepNavigation?: boolean
/** Compact variant, for steering a dialog rather than a full page. */
small?: boolean
/** Steps this run does not go through, greyed out and not clickable — a step the
* chosen path skips rather than one not reached yet. */
disabledIndices?: number[]
}
let {
@@ -21,7 +24,8 @@
statusByStep = [],
hasValidations = false,
allowStepNavigation = false,
small = false
small = false,
disabledIndices = []
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -75,15 +79,20 @@
{#each tabs ?? [] as step, index}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
{@const disabled = disabledIndices.includes(index)}
<li
class={classNames(
small
? 'flex items-center gap-1.5 px-1.5 py-0.5 hover:bg-gray-1200 rounded-md m-0.5'
: 'flex items-center gap-2 px-2 py-1 hover:bg-gray-1200 rounded-md m-0.5',
index <= maxReachedIndex || allowStepNavigation ? 'cursor-pointer' : 'cursor-not-allowed'
disabled
? 'cursor-not-allowed opacity-50'
: index <= maxReachedIndex || allowStepNavigation
? 'cursor-pointer'
: 'cursor-not-allowed'
)}
onclick={() => {
dispatch('click', { index })
if (!disabled) dispatch('click', { index })
}}
>
{#if statusByStep[index] === 'pending'}
@@ -94,7 +103,9 @@
small
? 'h-4 w-4 rounded-full flex items-center justify-center text-2xs'
: 'h-6 w-6 rounded-full flex items-center justify-center text-xs',
getStepColor(index, selectedIndex, statusByStep, maxReachedIndex)
disabled
? 'bg-gray-200'
: getStepColor(index, selectedIndex, statusByStep, maxReachedIndex)
)}
class:font-bold={selectedIndex === index}
>
@@ -105,7 +116,11 @@
<span
class={classNames(
'hidden sm:block',
selectedIndex === index ? 'font-semibold text-primary' : 'font-normal text-primary'
disabled
? 'font-normal text-disabled'
: selectedIndex === index
? 'font-semibold text-primary'
: 'font-normal text-primary'
)}
>
{step}
@@ -271,6 +271,33 @@
// Shared with the agent run viewer, which needs the same programmatic-scroll
// guard for the same reason.
const sticker = createBottomSticker()
// Per message: whether it is the last answer of its turn — per flow step run, since each
// run's last answer carries the only link to that run. Keyed on the answer's own job (a step
// label repeats when a step runs in a loop), and on answers rather than the next row: a
// flow's tool rows have their own job, and a stopped turn can end on a tool row.
// None in a turn still running, paused on the user included (their reply lands as a tool
// result, so a row shown during the pause would vanish again): which answer ends the turn is
// unknown until it does, and one followed by a tool call would leave the row's blank gap above
// it. A manual compaction loads without a turn of its own, so the last turn keeps its row.
const showsAnswerActions = $derived.by(() => {
const shows: boolean[] = new Array(messages.length).fill(false)
const answeredLater = new Set<string | undefined>()
let inRunningTurn = chatHost.loading && !chatHost.compacting
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role === 'user' || message.role === 'summary') {
answeredLater.clear()
inRunningTurn = false
} else if (message.role === 'assistant' && message.content) {
const run = message.jobId ?? message.stepName
shows[i] = !inRunningTurn && !answeredLater.has(run)
answeredLater.add(run)
}
}
return shows
})
function scrollDown() {
sticker.scrollToEnd(scrollElement)
}
@@ -831,6 +858,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{availableContext}
bind:editingMessageIndex
isLast={messageIndex === messages.length - 1}
showAnswerActions={showsAnswerActions[messageIndex]}
/>
{/each}
{#if freeTierExhausted}
@@ -125,6 +125,14 @@ import {
isWebSearchEnabledForProvider
} from '$lib/aiStore'
import type { WorkspaceMutationTarget } from './workspaceTools'
import { resolveSessionAccess } from './global/sessionAccess'
import type { SessionAccess } from './sessionCapabilities'
import { filterSessionTools } from './global/sessionToolset'
import {
listFolderInstructions,
type FolderInstruction,
type FolderInstructionsDelivery
} from './folderInstructions'
import {
loadWorkspaceSkills,
resolveGlobalPromptIdentity,
@@ -696,6 +704,10 @@ export class AIChatManager implements ChatViewHost {
// needs the preview pane; the global side-panel chat leaves it false. Reactive because
// `planModeAvailable` derives from it.
isSessionChat = $state(false)
// Undefined until a send or the assistant settings modal resolves it. Reactive: both
// tool views derive from it.
private sessionAccess = $state<SessionAccess | undefined>(undefined)
private sessionAccessGeneration = 0
autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
autoAcceptEditsActive = $derived(
this.autoAcceptEditsAvailable &&
@@ -717,7 +729,23 @@ export class AIChatManager implements ChatViewHost {
role: 'system',
content: ''
})
tools = $state<Tool<any>[]>([])
/** The sources the current mode assembled, concatenated. Private, because neither view
* over it is this list: a consumer reading it would advertise a tool set no request ever
* carries. Raw: every write replaces the list, and a deep proxy would reach the tool
* defs, which the session filter copies with `structuredClone` — that throws on a proxy. */
#assembledTools = $state.raw<Tool<any>[]>([])
/** Both views narrow the same way — concatenate every source, then filter — so a tool
* reaching either one can never arrive unfiltered. A session withholds what its user's
* capabilities do not cover; elsewhere `sessionAccess` is unset and nothing is dropped. */
#shipped = (planTools: Tool<any>[]): Tool<any>[] =>
filterSessionTools([...this.#assembledTools, ...planTools], this.sessionAccess)
/** What the request carries: the assembled tools plus the plan-mode transition the current
* posture offers. Read by the request path and by the YOLO disclosure. */
tools: Tool<any>[] = $derived(this.#shipped(this.planMode.tools))
/** What the assistant can call in this session, posture-independent: both plan-mode
* transitions, whichever one is offered right now. A reference answer, so flipping the
* autonomy picker must not change it. */
availableTools: Tool<any>[] = $derived(this.#shipped(this.planMode.availableTools))
helpers = $state<any | undefined>(undefined)
scriptEditorOptions = $state<ScriptOptions | undefined>(undefined)
@@ -1326,6 +1354,13 @@ export class AIChatManager implements ChatViewHost {
globalSkills = $state<AiSkillListItem[]>([])
private globalSkillsRefreshId = 0
// The `ai_instruction` resources readable in the operating workspace, loaded
// alongside skills. The prompt names their folders; the tool loop delivers each
// one's body the first time a call touches its folder, recording which call did.
private globalFolderInstructions: FolderInstruction[] = []
private globalFolderInstructionsRefreshId = 0
private folderInstructionsDeliveredBy = new Map<string, FolderInstructionsDelivery>()
// External MCP servers the user connected (resources of type `mcp`). Loaded
// asynchronously alongside skills; the MCP tools are only registered when
// this is non-empty, so a workspace with no connection pays no schema cost
@@ -1417,10 +1452,12 @@ export class AIChatManager implements ChatViewHost {
typeof this.systemMessage.content === 'string'
? this.systemMessage.content.length / tokenPerCharacter
: 0
const tools = this.tools
// Counts each tool's shared `def`, not the per-chat one `schemaFor` builds at send time,
// so a flow's input schema is missed. Accepted: the estimate only stands in until the
// provider reports usage, and the compaction trigger's headroom absorbs the gap.
const toolTokens =
this.tools.length > 0
? JSON.stringify(this.tools.map((t) => t.def)).length / tokenPerCharacter
: 0
tools.length > 0 ? JSON.stringify(tools.map((t) => t.def)).length / tokenPerCharacter : 0
return systemTokens + toolTokens
}
@@ -1764,7 +1801,7 @@ export class AIChatManager implements ChatViewHost {
try {
this.apiTools = await loadApiTools()
if (this.mode === AIMode.API) {
this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools]
this.#assembledTools = [searchDocsTool, readDocsPageTool, ...this.apiTools]
}
} catch (err) {
console.error('Error loading api tools', err)
@@ -2322,8 +2359,9 @@ export class AIChatManager implements ChatViewHost {
}
) {
if (!isAIModeVisible(mode)) return
// A session chat is GLOBAL for its whole life, and the plan gate reads that mode: moving
// it lifts the gate on a session the user still has set to Plan.
// A session chat is GLOBAL for its whole life, and two things read that mode: moving it
// lifts the plan gate on a session the user still has set to Plan, and hands the
// capability filter tools that declare no `requires`, so it fails closed to nothing.
if (this.isSessionChat && mode !== AIMode.GLOBAL) {
console.error(`Refusing to move a session chat to ${mode} mode: sessions are GLOBAL-only.`)
return
@@ -2346,7 +2384,7 @@ export class AIChatManager implements ChatViewHost {
customPrompt
)
this.systemMessage.content = this.systemMessage.content
this.tools = [...prepareScriptTools(currentModel, lang, context)]
this.#assembledTools = [...prepareScriptTools(currentModel, lang, context)]
this.helpers = {
getScriptOptions: () => {
return {
@@ -2378,7 +2416,7 @@ export class AIChatManager implements ChatViewHost {
this.systemMessage = prepareFlowSystemMessage(customPrompt)
this.systemMessage.content = this.systemMessage.content
this.appendFlowAiAgentProviders(this.systemMessage)
this.tools = [...flowTools]
this.#assembledTools = [...flowTools]
this.helpers = {
...(this.flowAiChatHelpers ?? {}),
getWorkspaceMutationTarget: this.getFlowWorkspaceMutationTarget
@@ -2386,27 +2424,28 @@ export class AIChatManager implements ChatViewHost {
} else if (mode === AIMode.NAVIGATOR) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareNavigatorSystemMessage(customPrompt)
this.tools = [this.changeModeTool, ...navigatorTools]
this.#assembledTools = [this.changeModeTool, ...navigatorTools]
this.helpers = {}
} else if (mode === AIMode.ASK) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareAskSystemMessage(customPrompt)
this.tools = [...askTools]
this.#assembledTools = [...askTools]
this.helpers = {}
} else if (mode === AIMode.API) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareApiSystemMessage(customPrompt)
this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools]
this.#assembledTools = [searchDocsTool, readDocsPageTool, ...this.apiTools]
this.helpers = {}
} else if (mode === AIMode.GLOBAL) {
this.configureGlobalMode()
void this.refreshGlobalIdentity()
void this.refreshGlobalSkills()
void this.refreshGlobalFolderInstructions()
void this.refreshMcpServers()
} else if (mode === AIMode.APP) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareAppSystemMessage(customPrompt)
this.tools = [...getAppTools()]
this.#assembledTools = [...getAppTools()]
this.helpers = this.appAiChatHelpers
}
}
@@ -2425,13 +2464,13 @@ export class AIChatManager implements ChatViewHost {
sessionId: this.sessionId,
operatingWorkspace: this.operatingWorkspace,
artifacts: this.artifacts,
access: this.sessionAccess,
getChatId: () => this.historyManager.getCurrentChatId(),
openArtifact: this.openArtifact
}
: {}),
testActiveFlow: async (storagePath: string, args?: Record<string, any>, memoryId?: string) =>
this.flowEditorFor(storagePath)?.testFlow(args, memoryId),
getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined),
attachedFiles: this.attachedFiles,
getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '',
setUserInstructions: (instructions: string) => {
@@ -2445,7 +2484,7 @@ export class AIChatManager implements ChatViewHost {
this.rebuildGlobalSystemMessage()
}
}
this.tools = assembleGlobalTools(opts)
this.#assembledTools = assembleGlobalTools(opts)
this.helpers = pipeline ? { ...baseHelpers, pipeline } : baseHelpers
this.systemMessage = assembleGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), opts)
this.syncArtifactsSession()
@@ -2455,7 +2494,9 @@ export class AIChatManager implements ChatViewHost {
previewTools: this.isSessionChat,
user: this.globalIdentity,
skills: this.globalSkills,
folderInstructions: this.globalFolderInstructions,
mcpServers: this.mcpServers,
access: this.sessionAccess,
sessionContext: this.sessionContextResolver?.(),
pipelineContext: this.pipelineAiChatHelpers?.getPipelineContext()
})
@@ -2479,6 +2520,25 @@ export class AIChatManager implements ChatViewHost {
}
}
// Same shape as refreshGlobalSkills.
refreshGlobalFolderInstructions = async (workspace = this.operatingWorkspace ?? '') => {
const refreshId = ++this.globalFolderInstructionsRefreshId
let instructions: FolderInstruction[] = []
try {
instructions = await listFolderInstructions(workspace)
} catch (e) {
console.error('Failed to load folder instructions', e)
}
if (refreshId !== this.globalFolderInstructionsRefreshId) {
return
}
this.globalFolderInstructions =
workspace === (this.operatingWorkspace ?? '') ? instructions : []
if (this.mode === AIMode.GLOBAL) {
this.configureGlobalMode()
}
}
// Same shape as refreshGlobalSkills. An identity that resolves after the operating
// workspace moved describes the workspace left behind, so it is dropped, not installed.
refreshGlobalIdentity = async (workspace = this.operatingWorkspace ?? '') => {
@@ -2530,6 +2590,23 @@ export class AIChatManager implements ChatViewHost {
this.systemMessage = { ...target, content: `${target.content}\n\n${section}` }
}
// Same shape as refreshGlobalSkills. Re-resolved per send rather than once for the
// session's life: the operating workspace can change between sends, and a transient
// failure resolves fail-open, so the next message re-asks rather than keeping that answer.
refreshSessionAccess = async (workspace = this.operatingWorkspace ?? '') => {
if (!this.isSessionChat || !workspace) {
this.sessionAccess = undefined
return
}
const generation = ++this.sessionAccessGeneration
const access = await resolveSessionAccess(workspace)
if (generation !== this.sessionAccessGeneration) return
this.sessionAccess = workspace === (this.operatingWorkspace ?? '') ? access : undefined
if (this.mode === AIMode.GLOBAL) {
this.configureGlobalMode()
}
}
// Rebuild the GLOBAL system message in place so an updated user instruction (persisted by
// the update_user_instructions tool) is picked up on the next chat-loop iteration, which
// re-reads this.systemMessage via a getter.
@@ -2941,7 +3018,7 @@ export class AIChatManager implements ChatViewHost {
try {
// Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider
// on each iteration. This is critical for changeModeTool (Navigator → Script/Flow)
// which reassigns this.tools, this.helpers, this.systemMessage mid-loop.
// which reassigns #assembledTools, this.helpers, this.systemMessage mid-loop.
const self = this
// Pinned for the whole turn, like the `workspace` the loop routes through:
// the global chat's operating workspace follows workspaceStore, so a switch
@@ -2966,7 +3043,7 @@ export class AIChatManager implements ChatViewHost {
return base
},
get tools() {
return [...self.tools, ...self.planMode.tools]
return self.tools
},
get helpers() {
return self.helpers
@@ -3033,13 +3110,8 @@ export class AIChatManager implements ChatViewHost {
console.error('Failed to record AI usage', e)
}
},
onBeforeIteration: async (tools, _helpers, modelProvider) => {
onBeforeIteration: async (modelProvider) => {
this.lastIterationModel = modelProvider
for (const tool of tools) {
if (tool.setSchema) {
await tool.setSchema(this.helpers)
}
}
}
})
if (this.isSessionChat && this.sessionId && result.tokenUsage.total > 0) {
@@ -3543,15 +3615,18 @@ export class AIChatManager implements ChatViewHost {
return false
}
}
// Session chats commit their workspace in beforeSend; the identity, skills and
// MCP servers must all match the committed workspace before the system prompt is
// sent. Settling them here rather than mid-turn also keeps the prompt — the
// cached prefix of every iteration — stable for the whole request.
// Session chats commit their workspace in beforeSend; the identity, skills, folder
// instructions, MCP servers and capabilities must all match the committed workspace
// before the system prompt is sent. Settling them here rather than mid-turn also
// keeps the prompt — the cached prefix of every iteration — stable for the whole
// request.
if (this.mode === AIMode.GLOBAL) {
await Promise.all([
this.refreshGlobalIdentity(this.operatingWorkspace ?? ''),
this.refreshGlobalSkills(this.operatingWorkspace ?? ''),
this.refreshMcpServers(this.operatingWorkspace ?? '')
this.refreshGlobalFolderInstructions(this.operatingWorkspace ?? ''),
this.refreshMcpServers(this.operatingWorkspace ?? ''),
this.refreshSessionAccess(this.operatingWorkspace ?? '')
])
}
// Stop/Escape during the beforeSend pre-flight aborted this send before any
@@ -4009,7 +4084,11 @@ export class AIChatManager implements ChatViewHost {
? {
onJobStarted: (job) => this.registerJob(job),
onJobStatus: (jobId, update) => this.updateJob(jobId, update),
onJobDetached: (jobId) => this.markJobDetached(jobId)
onJobDetached: (jobId) => this.markJobDetached(jobId),
folderInstructions: {
list: () => this.globalFolderInstructions,
deliveredBy: this.folderInstructionsDeliveredBy
}
}
: {}),
removeToolStatus: (id) => {
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { writable } from 'svelte/store'
import type { FlowAIChatHelpers } from './flow/core'
import type { PipelineAIChatHelpers } from './pipeline/core'
@@ -1679,7 +1679,7 @@ describe('AIChatManager queued messages', () => {
mocks.tryGetCurrentModel.mockReturnValue(a)
mocks.runChatLoop.mockImplementation(async (config: any) => {
// an iteration starts on B...
await config.onBeforeIteration?.([], config.helpers, b)
await config.onBeforeIteration?.(b)
// ...the user switches to C while B's request is in flight...
mocks.getCurrentModel.mockReturnValue(c)
mocks.tryGetCurrentModel.mockReturnValue(c)
@@ -1792,7 +1792,7 @@ describe('AIChatManager queued messages', () => {
// mid-loop switch to a model the deny-list doesn't know...
mocks.getCurrentModel.mockReturnValue(unlistedBlind)
mocks.tryGetCurrentModel.mockReturnValue(unlistedBlind)
await config.onBeforeIteration?.([], config.helpers, unlistedBlind)
await config.onBeforeIteration?.(unlistedBlind)
// ...its request carries the images and the provider rejects them
throw new Error('400 this model does not support image input')
})
@@ -4559,3 +4559,113 @@ describe('AIChatManager cross-tab run seams', () => {
expect(manager.queuedMessage).toBe('')
})
})
describe('AIChatManager tool views', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' })
mocks.tryGetCurrentModel.mockReturnValue({ provider: 'openai', model: 'gpt-4o' })
clearWorkspaceRoleCache()
})
// Every mode assigns the private base separately. A missed site leaves it empty on a fresh
// manager, which is what these catch — silently, since nothing throws on an empty tool set.
it.each([
AIMode.SCRIPT,
AIMode.FLOW,
AIMode.NAVIGATOR,
AIMode.ASK,
AIMode.API,
AIMode.GLOBAL,
AIMode.APP
])('assembles tools for %s', async (mode) => {
const manager = new AIChatManager()
await manager.changeMode(mode)
expect(manager.tools.length).toBeGreaterThan(0)
expect(manager.availableTools.length).toBeGreaterThan(0)
})
const OPERATOR_WHOAMI = {
username: 'op',
email: 'admin@test',
is_admin: false,
is_super_admin: false,
operator: true,
groups: [],
folders: [],
folders_read: []
}
// The capability filter is unit-tested against a profile handed to it directly; what is
// untested there is that the manager ever hands it one. This drives a real send and reads
// the toolset off the request, so moving the resolve out of the pre-flight, or dropping
// the rebuild when it lands, fails here rather than shipping an operator write tools.
it('withholds write and preview tools from an operator for the whole request', async () => {
onTestFinished(() => mocks.whoami.mockReset())
mocks.whoami.mockResolvedValue(OPERATOR_WHOAMI)
let sent: { tools: string[]; prompt: string; deployKinds: string[] } | undefined
mocks.runChatLoop.mockImplementation(async (config: any) => {
const deploy = config.tools.find((t: any) => t.def.function.name === 'deploy_workspace_item')
sent = {
tools: config.tools.map((t: any) => t.def.function.name),
prompt: config.systemMessage.content,
deployKinds: deploy?.def.function.parameters.properties.type.enum ?? []
}
return {
addedMessages: [],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = true
await manager.sendRequest({ instructions: 'add a script', mode: AIMode.GLOBAL })
expect(sent?.tools).toEqual(expect.arrayContaining(['list_workspace_items', 'run_script']))
// Deploying ships — a draft can predate the role change — but only for the kinds an
// operator's token can land.
expect(sent?.deployKinds).toContain('schedule')
expect(sent?.deployKinds).not.toContain('script')
for (const withheld of ['write_script', 'test_run_script']) {
expect(sent?.tools).not.toContain(withheld)
// The prompt ships beside the tools, so the rebuild must have run after the
// profile landed — otherwise it still instructs the model to call these.
expect(sent?.prompt).not.toContain(withheld)
}
})
// The assistant settings modal resolves the profile on open, before any send, so the
// list it shows is the one the first request will carry, prompt included.
it('narrows the toolset and prompt when the profile resolves outside a send', async () => {
onTestFinished(() => mocks.whoami.mockReset())
mocks.whoami.mockResolvedValue(OPERATOR_WHOAMI)
const manager = new AIChatManager()
manager.mode = AIMode.GLOBAL
manager.isSessionChat = true
manager.configureGlobalMode()
expect(manager.availableTools.map((t) => t.def.function.name)).toContain('write_script')
await manager.refreshSessionAccess()
expect(manager.availableTools.map((t) => t.def.function.name)).not.toContain('write_script')
expect(manager.systemMessage.content).not.toContain('write_script')
})
// Which transition each posture offers is planModeController.test.ts's; what this pins is
// that only `tools` follows the picker. `isSessionChat` is what offers plan mode at all.
it('holds availableTools steady across the autonomy picker', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
await manager.changeMode(AIMode.GLOBAL)
const names = (tools: { def: { function: { name: string } } }[]) =>
tools.map((t) => t.def.function.name)
const before = names(manager.availableTools)
expect(before).toEqual(expect.arrayContaining(['enter_plan_mode', 'exit_plan_mode']))
manager.setAutonomyMode(AIAutonomyMode.YOLO)
expect(names(manager.tools)).not.toContain('enter_plan_mode')
expect(names(manager.availableTools)).toEqual(before)
})
})
@@ -46,6 +46,7 @@
messageIndex: number
editingMessageIndex: number | null
isLast?: boolean
showAnswerActions?: boolean
}
let {
@@ -53,7 +54,8 @@
messageIndex,
availableContext,
editingMessageIndex = $bindable(null),
isLast = false
isLast = false,
showAnswerActions = true
}: Props = $props()
// The edit box edits a copy of THIS message's original context, not the live
@@ -82,8 +84,8 @@
{:else}
<div
class={twMerge(
'mb-2 min-w-0',
message.role === 'tool' && 'mb-1',
'mb-1 min-w-0',
message.role === 'user' && 'mb-2',
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
isLast && '!mb-12',
message.role !== 'user' || !chatHost.supportsMessageEditing
@@ -138,10 +140,22 @@
/>
</div>
{:else}
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
<!-- Assistant and tool rows carry their own 4px above and below their text, so every
pair of consecutive rows sits the same distance apart. -->
<div
class={twMerge(
'text-sm px-2',
message.role === 'user' && 'py-1',
message.role === 'tool' && 'text-primary'
)}
>
{#if message.role === 'assistant'}
<div class="px-[1px] group/answer"
><AssistantMessage {message} workspace={messageWorkspace} /></div
><AssistantMessage
{message}
workspace={messageWorkspace}
showActions={showAnswerActions}
/></div
>
{:else if message.role === 'tool'}
<div class="px-[1px]"
@@ -16,16 +16,20 @@
import { Bot, ExternalLink } from 'lucide-svelte'
import CopyButton from '$lib/components/common/button/CopyButton.svelte'
import { base } from '$lib/base'
import { displayDate } from '$lib/utils'
import { displayDate, truncateRev } from '$lib/utils'
interface Props {
message: DisplayMessage
// Workspace the message's paths are resolved against: the one the chat
// operates on, which is not always the one being navigated.
workspace: string | undefined
// Only the last answer of a run (a copilot turn, or a flow step's run) gets the copy /
// timestamp / run row: it stays in the layout while invisible, so on an answer between
// tool calls it would double the gap below it.
showActions?: boolean
}
let { message, workspace }: Props = $props()
let { message, workspace, showActions = true }: Props = $props()
// The run this answer came out of. Only a flow chat has one — a copilot turn runs in
// the browser — so the job link is absent rather than empty elsewhere.
@@ -137,7 +141,7 @@
the padding the message column already carries. -->
{#if stepName}
<div
class="flex items-center gap-2 -ml-6 mb-1 text-2xs text-tertiary"
class="flex items-center gap-2 -ml-6 pt-1 text-2xs text-tertiary"
title="Answered by {stepName}"
>
<Bot size={16} class="shrink-0" />
@@ -151,7 +155,7 @@
expanded={reasoningExpanded}
onToggle={() => (reasoningToggled = !reasoningExpanded)}
shimmer={reasoningStreaming}
class="mb-2"
class={message.content ? 'mb-1' : ''}
labelClass="truncate"
contentClass="font-main text-secondary {markdownProse.xs}"
>
@@ -160,16 +164,18 @@
{/if}
{#if s3Object}
<DisplayResult result={s3Object} workspaceId={workspace} noControls={true} />
<div class="py-1">
<DisplayResult result={s3Object} workspaceId={workspace} noControls={true} />
</div>
{:else if message.content}
<div class="w-full space-y-2 {markdownProse.sm}">
<div class="w-full space-y-2 py-1 {markdownProse.sm}">
<Markdown md={message.content} {plugins} />
</div>
{/if}
{#if message.content}
{#if message.content && showActions}
<!-- Kept in flow while invisible, so revealing it on hover does not nudge the message
below. A thinking-only row has no answer to copy, and the next row links its run. -->
below. A thinking-only row has no answer to copy, and its run's last answer links the run. -->
<div
class="flex items-center gap-2 text-2xs text-tertiary opacity-0 transition-opacity duration-150 group-hover/answer:opacity-100 focus-within:opacity-100"
>
@@ -185,7 +191,7 @@
class="inline-flex items-center gap-1 hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{jobId?.slice(0, 8)}</span></span>
<span>job <span class="font-mono">{truncateRev(jobId ?? '', 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/if}
@@ -94,9 +94,9 @@ callers that already know which section they mean, such as the "+" menu's Manage
return aiChatManager.operatingWorkspace ?? active ?? ''
})
// Exactly what the chat loop sends — plan mode's transition tool is registered
// alongside `tools` there, so listing only `tools` would under-report.
let tools = $derived(summarizeTools([...aiChatManager.tools, ...aiChatManager.planMode.tools]))
// The session's capability surface, not this turn's: the heading answers what the
// assistant can call here, so it must not shift when the autonomy picker does.
let tools = $derived(summarizeTools(aiChatManager.availableTools))
// `SidebarNavigation`'s item shape, which is what the instance and workspace settings
// navs are built from too.
@@ -149,6 +149,7 @@ callers that already know which section they mean, such as the "+" menu's Manage
if (!aiChatManager.loading && !aiChatManager.sendInFlight) {
void aiChatManager.refreshGlobalSkills()
void aiChatManager.refreshMcpServers()
void aiChatManager.refreshSessionAccess()
}
}
</script>
@@ -13,7 +13,7 @@
type WindmillItemKind,
type WorkspaceItemTargetKind
} from './workspaceItems.svelte'
import { safeHref } from './safeHref'
import { safeHref } from '$lib/utils/safeHref'
type Props = {
href?: string
@@ -0,0 +1,170 @@
<script lang="ts">
import type { ToolCodeDiff } from './shared'
import {
toolDiffLines,
visibleToolDiffRows,
type CharacterRange,
type ToolDiffLine
} from './toolCodeDiff'
import { toolCodeDiffLanguage } from './toolCodeDiffLanguage'
import { highlightedSourceLines } from './toolCodeDiffHighlight'
import hljs from 'highlight.js/lib/core'
import HighlightTheme from '$lib/components/HighlightTheme.svelte'
import { Button } from '$lib/components/common'
interface Props {
diff: ToolCodeDiff
streaming?: boolean
diffLines?: ToolDiffLine[]
}
let { diff, streaming = false, diffLines }: Props = $props()
let expansions = $state<Map<string, number>>(new Map())
const diffRows = $derived(diffLines ?? toolDiffLines(diff, streaming))
const visibleRows = $derived(visibleToolDiffRows(diffRows, expansions))
const language = $derived(toolCodeDiffLanguage(diff.lang))
const lastLines = $derived.by(() => {
let old = 0
let current = 0
for (const row of diffRows) {
if (row.oldLine) old = Math.max(old, row.oldLine)
if (row.newLine) current = Math.max(current, row.newLine)
}
return { old, current }
})
// Both number columns fit the longest line number, so it never runs into the +/- column.
const lineNumberWidth = $derived(
`max(0.875rem, ${String(Math.max(lastLines.old, lastLines.current)).length}ch)`
)
const highlighted = $derived.by(() => {
if (!hljs.getLanguage(language.name)) hljs.registerLanguage(language.name, language.register)
return {
original: highlightedSourceLines(diff.before, language.name, lastLines.old),
modified: highlightedSourceLines(diff.after, language.name, lastLines.current)
}
})
function highlightedLine(row: ToolDiffLine): string {
const source = row.kind === 'removed' ? highlighted.original : highlighted.modified
const lineNumber = row.kind === 'removed' ? row.oldLine : row.newLine
return lineNumber === undefined ? '' : (source[lineNumber - 1] ?? '')
}
function expand(key: string): void {
expansions = new Map(expansions).set(key, (expansions.get(key) ?? 0) + 1)
}
// Tab stops are measured from the line start, so a range's width is its end column minus its
// start column, never the width of the range text on its own. Counts UTF-16 units, the unit
// of Monaco's ranges and of `slice`, so an emoji ahead of a change does not shift it.
function displayColumns(content: string): number {
let columns = 0
for (let index = 0; index < content.length; index++) {
columns += content[index] === '\t' ? 4 - (columns % 4) : 1
}
return columns
}
function rangeStyle(content: string, range: CharacterRange): string {
const left = displayColumns(content.slice(0, range.start))
const width = range.extendsToEnd
? 'calc(100% - ' + left + 'ch)'
: displayColumns(content.slice(0, range.start + range.length)) - left + 'ch'
return 'left: ' + left + 'ch; width: ' + width
}
</script>
<HighlightTheme />
<div
class="tool-code-diff max-h-[400px] overflow-auto bg-surface-tertiary text-xs leading-[18px] text-primary"
style="--line-number: {lineNumberWidth}"
>
{#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'}
<Button
unifiedSize="2xs"
variant="subtle"
btnClasses="grid min-h-7 w-full grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)] items-center gap-0 rounded-none bg-surface-secondary px-0 py-1 text-left text-xs font-normal leading-[18px] text-hint hover:bg-surface-hover"
onclick={() => expand(row.key)}
>
<span class="text-right">...</span>
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem]">
<span></span><span class="text-right">...</span><span>...</span>
</span>
<span>{row.count} {row.kind === 'collapsed' ? 'unchanged lines' : 'more lines'}</span>
</Button>
{:else}
<div
class="diff-line-{row.kind} grid grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)]"
>
<span class="text-right text-hint">{row.oldLine ?? ''}</span>
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem] text-hint">
<span></span><span class="text-right">{row.newLine ?? ''}</span><span
>{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''}</span
>
</span>
<span
class="relative min-w-0 whitespace-pre {row.kind === 'added'
? 'bg-green-500/20'
: row.kind === 'removed'
? 'bg-red-500/20'
: ''}"
>
{#each row.changedRanges ?? [] as range}
<span
class="pointer-events-none absolute top-0 z-0 h-[18px] {row.kind === 'added'
? 'bg-green-500/25'
: 'bg-red-500/20'}"
style={rangeStyle(row.content, range)}
></span>
{/each}
<span class="relative z-[1]">{@html highlightedLine(row) || '&nbsp;'}</span>
</span>
</div>
{/if}
{/each}
</div>
<style>
.tool-code-diff {
font-family: Menlo, Monaco, 'Courier New', monospace;
/* Must match the tab stops `displayColumns` uses to place intraline highlights. */
tab-size: 4;
}
.diff-line-added > span:nth-child(-n + 2),
.diff-line-removed > span:nth-child(-n + 2) {
@apply text-hint;
}
.tool-code-diff :global(.hljs-keyword),
.tool-code-diff :global(.hljs-literal),
.tool-code-diff :global(.hljs-built_in) {
@apply text-blue-700;
}
.tool-code-diff :global(.hljs-string) {
@apply text-red-700;
}
.tool-code-diff :global(.hljs-title.class_),
.tool-code-diff :global(.hljs-type) {
@apply text-cyan-800;
}
.tool-code-diff :global(.hljs-title.function_) {
@apply text-primary;
}
.tool-code-diff :global(.hljs-attr),
.tool-code-diff :global(.hljs-subst) {
@apply text-primary;
}
.tool-code-diff :global(.hljs-number) {
@apply text-green-700;
}
</style>
@@ -0,0 +1,103 @@
<script lang="ts">
import type { ToolDisplayMessage } from './shared'
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
import ToolCodeDiffView from './ToolCodeDiffView.svelte'
import ToolPreviewCard from './ToolPreviewCard.svelte'
import {
argumentsCarryWholeFile,
diffLineCounts,
toolCodeDiff,
toolDiffLineCounts,
toolDiffLines
} from './toolCodeDiff'
interface Props {
message: ToolDisplayMessage
}
let { message }: Props = $props()
const diff = $derived(toolCodeDiff(message))
const isStreaming = $derived(Boolean(message.isStreamingArguments))
const hasDiff = $derived(Boolean(diff && (diff.before !== '' || diff.after !== '')))
// The card body is a bordered box: opened with nothing in it, it draws an empty line.
const hasBody = $derived(hasDiff || Boolean(message.error))
// Keyed by call id: a bare flag would carry the expansion onto the next message that
// reuses this instance. An active or failed call opens by itself, and remains open
// when it settles so the new diff does not disappear under the user. A call whose arguments
// are a whole file (`write_script`, full-code `edit_code`) opens only on failure. The key
// also changes on failure, so a collapse made while running cannot hide the error.
const toggleKey = $derived(`${message.tool_call_id}:${message.error ? 'error' : ''}`)
let toggled = $state<{ id: string; open: boolean } | undefined>(undefined)
const opensByDefault = $derived(
Boolean(
message.error ||
(hasDiff &&
!argumentsCarryWholeFile(message) &&
(message.isLoading || message.isQueued || message.isStreamingArguments))
)
)
$effect(() => {
if (opensByDefault && toggled?.id !== toggleKey) {
toggled = { id: toggleKey, open: true }
}
})
const expanded = $derived(
hasBody && (toggled?.id === toggleKey ? toggled.open : opensByDefault)
)
const lines = $derived(diff && expanded ? toolDiffLines(diff, isStreaming) : undefined)
const counts = $derived(
diff && expanded
? isStreaming
? diffLineCounts(diff, true)
: toolDiffLineCounts(lines ?? [])
: undefined
)
const isRunning = $derived(Boolean(message.isLoading && !message.needsConfirmation))
const showPreviewChip = $derived(
Boolean(message.previewCard && !message.isLoading && !message.error)
)
</script>
{#snippet headerRight()}
<div class="flex items-center gap-2 shrink-0">
{#if counts && (counts.added > 0 || counts.removed > 0)}
<span class="font-mono text-2xs">
<span class="text-green-600 dark:text-green-400">+{counts.added}</span>
<span class="text-red-600 dark:text-red-400">−{counts.removed}</span>
</span>
{/if}
{#if showPreviewChip && message.previewCard}
<ToolPreviewCard card={message.previewCard} />
{/if}
</div>
{/snippet}
<ChatCollapsibleCard
label={message.content}
{expanded}
onToggle={() => (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}
<ToolCodeDiffView {diff} diffLines={lines} streaming={isStreaming} />
{/if}
{#if message.error}
<div
class="px-3 py-2 text-2xs text-red-600 dark:text-red-400 whitespace-pre-wrap break-words {hasDiff
? 'border-t border-border-light'
: ''}"
>
{message.error}
</div>
{/if}
</ChatCollapsibleCard>
@@ -10,9 +10,11 @@
FileText,
PanelRight,
Lock,
ExternalLink
ExternalLink,
BookOpen
} from 'lucide-svelte'
import { base } from '$lib/base'
import { truncateRev } from '$lib/utils'
import {
EXIT_PLAN_MODE_TOOL,
isPlanCardTool,
@@ -26,7 +28,7 @@
import { getChatViewHost } from './chatViewHost'
const chatHost = getChatViewHost()
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
import { isActiveUserQuestion, webSearchResultOf, type ToolDisplayMessage } from './shared'
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
@@ -40,6 +42,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 +136,7 @@
// A call that inspected a run rather than starting one gets the same card, bound to
// the job it named — what happened in a run reads the same either way.
const isRunCard = $derived(Boolean(message.runForm || message.inspectedRun))
const isDiffCard = $derived(Boolean(message.codeDiff) || hasToolCodeDiff(message.toolName))
// The preview chip sits on the header row (to the right of the tool-call text);
// shown once the tool settled, never while loading/erroring/awaiting confirmation.
@@ -140,6 +145,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
)
</script>
{#if activeUserQuestion}
@@ -156,8 +173,22 @@
<span class="text-2xs text-tertiary truncate">{message.toolName}</span>
{/if}
</div>
{:else if message.heldForFolderInstructions}
<!-- The call never ran: the model gets the folder's instructions and calls it again.
A diff or run card here would show a write that did not happen. -->
<div class="font-mono text-xs flex items-center gap-2 py-0.5 my-0.5 min-w-0">
<BookOpen class="w-3.5 h-3.5 text-tertiary shrink-0" />
<span class="font-medium text-2xs text-tertiary shrink-0">
{message.content}
</span>
{#if message.toolName}
<span class="text-2xs text-tertiary truncate">{message.toolName} held until read</span>
{/if}
</div>
{:else if isRunCard}
<RunScriptCard {message} />
{:else if isDiffCard}
<ToolDiffCard {message} />
{:else if planState}
<!-- Same lean shape as a tool call below: a header row that collapses into the
transcript, with everything else in one box under it. -->
@@ -261,7 +292,7 @@
class="shrink-0 inline-flex items-center gap-1 font-main text-2xs text-tertiary hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{message.jobId?.slice(0, 8)}</span></span>
<span>job <span class="font-mono">{truncateRev(message.jobId ?? '', 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/snippet}
@@ -281,7 +312,7 @@
weight alone: queued calls (waiting their turn behind the executing tool)
are faded, the running one sweeps, a settled one is plain. -->
<ChatCollapsibleCard
label={message.content}
{label}
expanded={isExpanded}
onToggle={() => (isExpanded = !isExpanded)}
toggleable={detailsAvailable || message.isStreamingArguments === true}
@@ -343,8 +374,8 @@
{#if visibleActions.length > 0}
<ToolMessageActions actions={visibleActions} />
{:else if message.webSearchSources?.length && !message.error}
<WebSearchSourcesDisplay sources={message.webSearchSources} />
{:else if sources?.length && !message.error}
<WebSearchSourcesDisplay {sources} favicons={message.webSearchSources !== undefined} />
{:else}
<ToolContentDisplay
title="Result"
@@ -1,31 +1,25 @@
<script lang="ts">
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 @@
<div class="flex flex-col max-h-40 overflow-y-auto">
{#each uniqueSources as source (source.url)}
{@const hostname = hostnameOf(source.url)}
<!-- A blank title is a title the row cannot show, so it names the host instead of
rendering a link with no text. -->
{@const title = source.title?.trim() || undefined}
<a
href={source.url}
target="_blank"
@@ -80,8 +77,8 @@
onerror={() => failedFavicons.add(hostname)}
/>
{/if}
<span class="text-2xs text-primary truncate">{source.title ?? hostname}</span>
{#if source.title}
<span class="text-2xs text-primary truncate">{title ?? hostname}</span>
{#if title}
<span class="text-2xs text-tertiary truncate shrink-0 max-w-32">{hostname}</span>
{/if}
</a>
@@ -446,7 +446,8 @@ export async function parseAnthropicCompletion(
toolCall,
helpers,
toolCallbacks: callbacks,
workspace: options?.workspace
workspace: options?.workspace,
messages
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { createToolDef, type Tool, type ToolCallbacks } from '../shared'
import { createToolDef, type ToolCallbacks } from '../shared'
import { NONE, type SessionTool } from '../sessionCapabilities'
import { artifactOverflowBytes, MAX_ARTIFACT_BYTES, normalizeChangeNote } from './artifactLimits'
import {
currentVersion,
@@ -98,8 +99,9 @@ function targetsPlan(args: unknown, h: ArtifactToolHelpers): boolean {
)
}
export const artifactTools: Tool<{}>[] = [
export const artifactTools: SessionTool<{}>[] = [
{
requires: NONE,
def: createToolDef(
createArtifactSchema,
'create_artifact',
@@ -156,6 +158,7 @@ export const artifactTools: Tool<{}>[] = [
}
},
{
requires: NONE,
def: createToolDef(
updateArtifactSchema,
'update_artifact',
@@ -208,6 +211,7 @@ export const artifactTools: Tool<{}>[] = [
}
},
{
requires: NONE,
def: createToolDef(
listArtifactsSchema,
'list_artifacts',
@@ -242,6 +246,7 @@ export const artifactTools: Tool<{}>[] = [
}
},
{
requires: NONE,
def: createToolDef(
readArtifactSchema,
'read_artifact',
@@ -303,6 +308,7 @@ export const artifactTools: Tool<{}>[] = [
}
},
{
requires: NONE,
def: createToolDef(
listArtifactVersionsSchema,
'list_artifact_versions',
@@ -569,6 +569,32 @@ const assistantTools = (...ids: string[]): ChatCompletionMessageParam => ({
function: { name: 'do_thing', arguments: '{}' }
}))
})
describe('runChatLoop per-chat tool schemas', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.resolveRequestReasoning.mockReturnValue(undefined)
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({ shouldContinue: false, tokenUsage })
})
// The def `schemaFor` returns is the chat's own: it must be what the provider is sent and
// what the tool is dispatched with, while the shared tool object keeps its own.
it('sends and dispatches the def schemaFor returns, leaving the shared tool alone', async () => {
const base = { type: 'function', function: { name: 'dyn', parameters: {} } } as any
const perChat = {
type: 'function',
function: { name: 'dyn', parameters: { marked: 1 } }
} as any
const shared = { def: base, fn: async () => '', schemaFor: async () => perChat }
await runChatLoop({ ...createConfig({ workspace: `ws-${randomUUID()}` }), tools: [shared] })
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][2]).toEqual([perChat])
expect(mocks.parseOpenAIResponsesCompletion.mock.calls[0][4][0].def).toBe(perChat)
expect(shared.def).toBe(base)
})
})
const tool = (id: string): ChatCompletionMessageParam => ({
role: 'tool',
tool_call_id: id,
@@ -71,13 +71,8 @@ export interface ChatLoopConfig {
* lets the caller recover partial output if the loop throws or is aborted.
*/
addedMessages?: ChatCompletionMessageParam[]
/** Called before each iteration (e.g. to refresh tool schemas, or to record
* which model the iteration is about to use). */
onBeforeIteration?: (
tools: Tool<any>[],
helpers: any,
modelProvider: ReasoningProviderModel
) => Promise<void>
/** Called before each iteration (e.g. to record which model it is about to use). */
onBeforeIteration?: (modelProvider: ReasoningProviderModel) => Promise<void>
/** Fired for each completed provider response, before the loop continues. The
* loop can fail or be aborted at any iteration, so spend has to be handed over
* as it happens — a callback only at the end would discard everything the
@@ -399,7 +394,6 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
// Re-read these from config each iteration so that mode changes
// (e.g. changeModeTool in Navigator) take effect immediately.
// Callers can use JS getter properties to provide dynamic values.
const tools = config.tools
const helpers = config.helpers
const systemMessage = config.systemMessage
const modelProvider = config.modelProvider
@@ -411,8 +405,11 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
!unsupportedWebSearchCache.has(webSearchCacheKey)
if (onBeforeIteration) {
await onBeforeIteration(tools, helpers, modelProvider)
await onBeforeIteration(modelProvider)
}
const tools = await Promise.all(
config.tools.map(async (t) => (t.schemaFor ? { ...t, def: await t.schemaFor(helpers) } : t))
)
const pendingUserMessage = getPendingUserMessage?.()
@@ -147,7 +147,10 @@ export interface ChatViewHost {
* True where the bytes are forwarded somewhere — object storage — rather than read
* in the browser. */
attachmentsAsBlobs: boolean
tools: Tool<any>[]
/** What this chat's next request carries, the current posture's plan-mode transition
* included. A consumer narrows it by tool metadata; assembling its own list from the
* sources behind it is how the advertised set and the sent set drift apart. */
readonly tools: readonly Tool<any>[]
autonomyMode: AIAutonomyMode
setAutonomyMode: (mode: AIAutonomyMode) => void
readonly autoAcceptEditsActive: boolean
@@ -6,10 +6,10 @@ import { datatableReference } from '$lib/components/dbTypes'
import {
createToolDef,
executeTestRun,
type Tool,
type ToolDisplayMessage,
type ChatJobResultFormat
} from './shared'
import { NONE, RUN_PREVIEW, type SessionTool } from './sessionCapabilities'
/**
* Workspace-scoped datatable tools, with no app whitelist and no creation policy.
@@ -246,9 +246,10 @@ export function formatChatJobCompletion(
* The unrestricted workspace datatable tools, for registration in global mode.
* Helper-free: each tool reads `workspace` directly from the tool call params.
*/
export function getDatatableTools(): Tool<{}>[] {
export function getDatatableTools(): SessionTool<{}>[] {
return [
{
requires: NONE,
def: getListDatatablesToolDef(),
planModeSafe: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
@@ -298,6 +299,7 @@ export function getDatatableTools(): Tool<{}>[] {
}
},
{
requires: NONE,
def: getGetDatatableTableSchemaToolDef(),
planModeSafe: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
@@ -337,6 +339,8 @@ export function getDatatableTools(): Tool<{}>[] {
}
},
{
// `runScript` below goes through /jobs/run/preview, which jobs.rs refuses operators.
requires: RUN_PREVIEW,
def: getExecDatatableSqlToolDef(),
requiresConfirmation: true,
confirmationMessage: 'Execute SQL on datatable',
@@ -1,4 +1,4 @@
import type { Tool } from '../shared'
import { NONE, type SessionTool } from '../sessionCapabilities'
import type { ChatCompletionTool } from 'openai/resources/index.mjs'
import { DocumentationService } from '$lib/gen'
@@ -33,7 +33,8 @@ const READ_DOCS_PAGE_TOOL: ChatCompletionTool = {
}
}
export const readDocsPageTool: Tool<{}> = {
export const readDocsPageTool: SessionTool<{}> = {
requires: NONE,
def: READ_DOCS_PAGE_TOOL,
planModeSafe: true,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -85,7 +86,8 @@ const SEARCH_DOCS_TOOL: ChatCompletionTool = {
}
}
export const searchDocsTool: Tool<{}> = {
export const searchDocsTool: SessionTool<{}> = {
requires: NONE,
def: SEARCH_DOCS_TOOL,
planModeSafe: true,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { DataMetricService, WorkspaceService } from '$lib/gen'
import { createToolDef, type Tool } from './shared'
import { createToolDef } from './shared'
import { NONE, type SessionTool } from './sessionCapabilities'
/**
* Workspace-scoped DuckLake tools, the pipeline counterpart to `list_datatables`
@@ -69,9 +70,10 @@ const NO_DATA_METRICS_NOTE =
const DEFAULT_DATA_METRICS_LIMIT = 200
/** The workspace DuckLake tools, for registration in global mode. */
export function getDucklakeTools(): Tool<{}>[] {
export function getDucklakeTools(): SessionTool<{}>[] {
return [
{
requires: NONE,
def: listDataMetricsToolDef,
planModeSafe: true,
showDetails: true,
@@ -106,6 +108,7 @@ export function getDucklakeTools(): Tool<{}>[] {
}
},
{
requires: NONE,
def: listDucklakesToolDef,
planModeSafe: true,
fn: async ({ workspace, toolId, toolCallbacks }) => {
@@ -8,7 +8,8 @@
*/
import { z } from 'zod'
import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
import { createToolDef, type Tool } from '../shared'
import { createToolDef } from '../shared'
import { NONE, type SessionTool } from '../sessionCapabilities'
import {
readFile,
searchFilesInWorker,
@@ -95,7 +96,8 @@ const searchFilesToolDef = createToolDef(
'Search the user-attached files with a regular expression and return matching lines with their line numbers. Use this to locate content before reading a specific window with read_file.'
)
export const searchFilesTool: Tool<{}> = {
export const searchFilesTool: SessionTool<{}> = {
requires: NONE,
def: searchFilesToolDef,
planModeSafe: true,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
@@ -164,7 +166,8 @@ const readFileToolDef = createToolDef(
'Read a bounded window of lines from a user-attached file. Returns each line prefixed with its 1-based number (`<n>→<content>`) plus a pagination note. Files are not in context, so use this to inspect their contents.'
)
export const readFileTool: Tool<{}> = {
export const readFileTool: SessionTool<{}> = {
requires: NONE,
def: readFileToolDef,
planModeSafe: true,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
@@ -193,7 +196,7 @@ export const readFileTool: Tool<{}> = {
}
}
export const fileTools: Tool<{}>[] = [searchFilesTool, readFileTool]
export const fileTools: SessionTool<{}>[] = [searchFilesTool, readFileTool]
function rosterLine(f: AttachedFile): string {
// Message rows are addressed by their stable id (names may collide); session
@@ -302,7 +302,7 @@ const patchFlowJsonToolDef = createToolDef(
'patch_flow_json',
'Make a quick exact text edit in the current compact flow JSON. Prefer this for small localized changes; use set_flow_json for larger structural rewrites.'
)
// Will be overridden by setSchema
// Replaced per chat by schemaFor
const testRunFlowSchema = z.object({
args: z
.object({})
@@ -435,11 +435,10 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
contextName: 'flow'
})
},
setSchema: async function (helpers: FlowAIChatHelpers) {
await buildSchemaForTool(this.def, async () => {
const flowInputsSchema = await helpers.getFlowInputsSchema()
return flowInputsSchema
})
schemaFor: async (helpers: FlowAIChatHelpers) => {
const def = structuredClone(testRunFlowToolDef)
await buildSchemaForTool(def, () => helpers.getFlowInputsSchema())
return def
},
requiresConfirmation: true,
confirmationMessage: 'Run a test of the current flow',
@@ -465,6 +464,18 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
const stepId = args.stepId
const stepArgs = args.args || {}
// An agent step runs on the step's own configuration with this run's changes on top, which
// the global chat's run form assembles; this tool sends only what the model named.
if (findModuleInFlow(flow.value, stepId)?.value.type === 'aiagent') {
toolCallbacks.setToolStatus(toolId, {
content: `Step "${stepId}" is an AI agent step`,
error: 'AI agent steps cannot be tested from this chat'
})
throw new Error(
'AI agent steps cannot be tested from this chat. Test it from the step\'s "Test this step" tab.'
)
}
return executeFlowStepTestRun({
flowValue: flow.value,
stepId,
@@ -0,0 +1,220 @@
import { ResourceService } from '$lib/gen'
import { truncateForPrompt } from './skills/skillResources'
/**
* Folder instructions are resources of this type: a markdown file resource
* (`format_extension = 'md'`) whose `value.content` the assistant follows for
* every item under the directory the resource lives in, like an AGENTS.md.
*/
export const FOLDER_INSTRUCTIONS_RESOURCE_TYPE = 'ai_instruction'
/** Bytes of one instruction body a tool result may carry. */
export const MAX_FOLDER_INSTRUCTIONS_LENGTH = 32 * 1024
/** Bytes all the bodies one tool result carries may add up to, so nested folders
* cannot stack several full-size bodies onto a single result. */
export const MAX_FOLDER_INSTRUCTIONS_TOTAL = 48 * 1024
/** Scopes the system prompt names. Past this the list is cut, not the delivery:
* a scope left out still has its instructions delivered on first touch. */
export const MAX_LISTED_FOLDER_INSTRUCTIONS = 50
export type FolderInstruction = {
/** The `ai_instruction` resource. */
path: string
/** The directory it covers, with a trailing `/`: `f/billing/AGENTS` covers `f/billing/`. */
scope: string
}
export function folderInstructionScope(path: string): string {
return path.slice(0, path.lastIndexOf('/') + 1)
}
const PAGE_SIZE = 100
/** Guard against a paging bug looping forever, not a product cap. */
const MAX_PAGES = 20
/** Every folder instruction the user can read in the workspace. Read access is the
* resource's own ACL, so a folder's instructions reach exactly who can see them. */
export async function listFolderInstructions(workspace: string): Promise<FolderInstruction[]> {
if (!workspace) return []
const rows: FolderInstruction[] = []
for (let page = 1; page <= MAX_PAGES; page++) {
const resources = await ResourceService.listResource({
workspace,
resourceType: FOLDER_INSTRUCTIONS_RESOURCE_TYPE,
page,
perPage: PAGE_SIZE
})
rows.push(...resources.map((r) => ({ path: r.path, scope: folderInstructionScope(r.path) })))
if (resources.length < PAGE_SIZE) break
}
return rows
}
/** Workspace paths a tool call names: every string argument called `path` or
* `*_path` that is a `u/`, `f/` or `g/` path, nested ones included — a trigger's target
* sits in `config.path`, and missing it would let that write through unheld. */
export function workspacePathsInArgs(args: unknown, depth = 0): string[] {
if (!args || typeof args !== 'object' || depth > 4) return []
if (Array.isArray(args)) return args.flatMap((v) => workspacePathsInArgs(v, depth + 1))
return Object.entries(args as Record<string, unknown>).flatMap(([key, value]) =>
typeof value === 'string'
? (key === 'path' || key.endsWith('_path')) && /^[ufg]\/[^/]+/.test(value)
? [value]
: []
: workspacePathsInArgs(value, depth + 1)
)
}
/** The instructions covering any of `paths`, outermost scope first, so a nested
* folder's instructions come last and read as the more specific ones. A path naming
* the folder itself (`f/billing`, as a pipeline's is) is covered too. */
export function instructionsCovering(
instructions: readonly FolderInstruction[],
paths: readonly string[]
): FolderInstruction[] {
return instructions
.filter((i) => paths.some((p) => `${p}/`.startsWith(i.scope)))
.sort((a, b) => a.scope.length - b.scope.length || a.path.localeCompare(b.path))
}
/** What a chat hands the tool loop: the instructions in play, and which tool call
* delivered which of them.
*
* A delivery counts only while its tool message is still in the conversation and
* carries the text: the id is recorded because a script quoting the tag in a result
* must not pass for a delivery, and nothing is marked on the message object because
* the completions path sends that to the provider verbatim. So compaction or a
* restored chat gets the instructions again. */
export type FolderInstructionsContext = {
list: () => readonly FolderInstruction[]
deliveredBy: Map<string, FolderInstructionsDelivery>
}
/** Keyed by workspace too: the chat keeps its conversation across a switch of
* operating workspace, and the same path there can hold different instructions. */
export type FolderInstructionsDelivery = { workspace: string; paths: readonly string[] }
/** Instructions delivered by the tool results in `messages`, split by whether the
* model has read them. Results after the latest assistant message answer the batch
* still being processed: the model has not seen those yet, so a delivery there
* must not let a change later in the same batch through. */
function deliveredIn(
ctx: FolderInstructionsContext,
messages: readonly unknown[],
workspace: string
): { read: Set<string>; thisBatch: Set<string> } {
const read = new Set<string>()
const thisBatch = new Set<string>()
let lastAssistant = -1
messages.forEach((m, i) => {
if ((m as { role?: unknown } | undefined)?.role === 'assistant') lastAssistant = i
})
messages.forEach((m, i) => {
const { tool_call_id: id, content } = (m ?? {}) as { tool_call_id?: unknown; content?: unknown }
if (typeof id !== 'string' || typeof content !== 'string') return
const delivery = ctx.deliveredBy.get(id)
if (delivery?.workspace !== workspace) return
for (const p of delivery.paths) {
// The id alone is not enough: a call stopped mid-run is answered with a
// placeholder under the same id, which carries no instructions.
if (content.includes(openingTag(p))) (i < lastAssistant ? read : thisBatch).add(p)
}
})
return { read, thisBatch }
}
/** The instructions covering the paths a tool call names that the model has not
* read yet, as the text to hand it, or undefined when there are none. `paths` are
* the bodies the text carries; one already carried earlier in the same batch is
* pointed to rather than repeated. A body that no longer reads (deleted or moved
* since the listing) is left out rather than failing the call it rides on. */
export async function pendingFolderInstructions(
ctx: FolderInstructionsContext | undefined,
messages: readonly unknown[],
args: unknown,
workspace: string
): Promise<{ paths: string[]; text: string } | undefined> {
if (!ctx || !workspace) return undefined
const paths = workspacePathsInArgs(args)
if (paths.length === 0) return undefined
const { read, thisBatch } = deliveredIn(ctx, messages, workspace)
const pending = instructionsCovering(ctx.list(), paths).filter((i) => !read.has(i.path))
const inBatch = pending.filter((i) => thisBatch.has(i.path))
const blocks = (
await Promise.all(
pending
.filter((i) => !thisBatch.has(i.path))
.map(async (instruction) => {
try {
return {
instruction,
body: await readFolderInstructionBody(workspace, instruction.path)
}
} catch (e) {
console.error(`Failed to read folder instructions ${instruction.path}`, e)
return undefined
}
})
)
).filter((b) => b !== undefined)
if (blocks.length === 0 && inBatch.length === 0) return undefined
const pointers = inBatch.map(
(i) =>
`The instructions for \`${i.scope}\` (${i.path}) are in an earlier result of this same batch of tool calls.`
)
return {
paths: blocks.map((b) => b.instruction.path),
text: [formatFolderInstructions(blocks), ...pointers].filter(Boolean).join('\n\n')
}
}
export async function readFolderInstructionBody(workspace: string, path: string): Promise<string> {
const value = (await ResourceService.getResourceValue({ workspace, path })) as
| { content?: unknown }
| undefined
if (typeof value?.content !== 'string') {
throw new Error(`resource ${path} has no string "content"`)
}
return value.content
}
function openingTag(path: string): string {
return `<folder_instructions path="${path}"`
}
export function formatFolderInstructions(
blocks: readonly { instruction: FolderInstruction; body: string }[]
): string {
// The budget goes innermost first: the most specific instructions are the last to
// be cut. An outer body the budget no longer reaches keeps its tag, so it still
// counts as delivered, with the cut marked.
let remaining = MAX_FOLDER_INSTRUCTIONS_TOTAL
const bodies = [...blocks].reverse().map(({ body }) => {
const cut = truncateForPrompt(body, Math.min(MAX_FOLDER_INSTRUCTIONS_LENGTH, remaining))
remaining = Math.max(0, remaining - new TextEncoder().encode(cut).byteLength)
return cut
})
bodies.reverse()
return blocks
.map(
({ instruction }, i) =>
`${openingTag(instruction.path)} scope="${instruction.scope}">\n${bodies[i]}\n</folder_instructions>`
)
.join('\n\n')
}
/** The system prompt section naming the scopes that carry instructions. */
export function getFolderInstructionsPromptSection(
instructions: readonly FolderInstruction[]
): string {
if (instructions.length === 0) return ''
const scopes = [...new Set(instructions.map((i) => i.scope))].sort()
const listed = scopes.slice(0, MAX_LISTED_FOLDER_INSTRUCTIONS)
const more = scopes.length > listed.length ? ` (and ${scopes.length - listed.length} more)` : ''
return `\n\nFOLDER INSTRUCTIONS:
These folders carry instructions (ai_instruction resources) for every item under them: ${listed.map((s) => `\`${s}\``).join(', ')}${more}.
- The first time a tool call names a path under one of them, its instructions are delivered in a <folder_instructions> block of the tool result. A call that would change something is held back until you have them: read them, then make the call again.
- Follow them for all work under that folder, together with any WORKSPACE INSTRUCTIONS and USER INSTRUCTIONS above: they add to those, they do not replace them.
- Only where two of them conflict on the same point, the more specific one wins: a nested folder's instructions over its parent folder's, a folder's over the WORKSPACE INSTRUCTIONS, and both over the USER INSTRUCTIONS, which are personal preferences. The user's explicit requests in this chat come before all of them.`
}
@@ -2043,6 +2043,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 },
@@ -3403,12 +3421,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<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
@@ -3420,6 +3443,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', () => {
@@ -6494,6 +6520,237 @@ describe('global AI tools', () => {
})
})
// An agent step has no job of its own, so it runs as a flow preview of itself alone, opened on
// the same rows and the same values as the flow editor's own step test. A setting the step
// leaves alone is not offered at all, so a mounted widget cannot invent a value for it: an
// `enabled_tools` the form synthesized empty would advertise none of the agent's tools.
it("test_run_step opens an aiagent step on the step's own configuration", async () => {
const providerValue = { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' }
let opened: RunFormDisplay | undefined
await callGlobalTool('write_flow', {
path: 'f/flows/agent-step',
summary: 'Flow with an agent step',
modules: JSON.stringify([
{
id: 'ask',
value: {
type: 'aiagent',
tools: [
{
id: 'b',
summary: 'get_code',
value: {
tool_type: 'flowmodule',
type: 'rawscript',
language: 'bun',
content: 'export async function main() { return 1 }',
input_transforms: {}
}
}
],
input_transforms: {
provider: { type: 'static', value: providerValue },
user_message: { type: 'static', value: 'authored question' }
}
}
}
])
})
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{
path: 'f/flows/agent-step',
stepId: 'ask',
args: { user_message: 'What is the capital of France?' }
},
{
...toolCallbacks,
requestRunArgs: async (_toolId, form) => {
opened = form
return form.args
}
}
)
)
// The step configures these two, so the form offers them, filled in with what the step
// holds — and the model's own message wins over the authored one.
expect(opened?.args).toMatchObject({
provider: providerValue,
user_message: 'What is the capital of France?'
})
// Those two plus the rows the editor always shows, and nothing else the step is silent
// about: `enabled_tools` above all, whose synthesized empty value would advertise no tools.
expect(Object.keys(opened?.schema?.properties ?? {})).toEqual([
'provider',
'user_message',
'system_prompt'
])
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
value: {
modules: [
{
id: 'ask',
value: expect.objectContaining({
type: 'aiagent',
input_transforms: {
provider: { type: 'javascript', expr: 'flow_input.provider' },
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
// Every row of the form runs from it, the blank ones too.
system_prompt: { type: 'javascript', expr: 'flow_input.system_prompt' }
}
})
}
]
},
args: { provider: providerValue, user_message: 'What is the capital of France?' },
// Filed under the flow, as the editor's step test does: a managed memory keyed on a
// named memory id is found under it.
path: 'f/flows/agent-step'
}
})
})
// What the form submits is what runs, as in the editor's step test: a field cleared over the
// step's expression runs cleared rather than falling back to the expression, whether the
// mounted form sends it as the empty string or the bypass posture sends the form unchanged.
it.each([
{
answer: 'the mounted form',
// A field the form could not fill in comes back blank, never absent: text as the empty
// string, a list as `[]`.
callbacks: {
requestRunArgs: async (_toolId: string, form: any) => ({
...form.args,
user_message: '',
enabled_tools: form.args.enabled_tools ?? []
})
}
},
{
answer: 'the bypass posture',
callbacks: {
shouldAutoAcceptToolConfirmations: () => true,
requestRunArgs: async (_toolId: string, form: any) => form.args
}
}
])(
'test_run_step runs an aiagent field left blank by $answer as blank',
async ({ callbacks }) => {
await callGlobalTool('write_flow', {
path: 'f/flows/agent-expr',
summary: 'Agent fed by an earlier step',
modules: JSON.stringify([
{
id: 'first',
value: {
type: 'rawscript',
language: 'bun',
content: 'export async function main() { return { question: "hi" } }',
input_transforms: {}
}
},
{
id: 'ask',
value: {
type: 'aiagent',
tools: [],
input_transforms: {
provider: {
type: 'static',
value: { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' }
},
user_message: { type: 'javascript', expr: 'results.first.question' },
enabled_tools: { type: 'javascript', expr: 'results.first.tools' }
}
}
}
])
})
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{ path: 'f/flows/agent-expr', stepId: 'ask', args: {} },
{ ...toolCallbacks, ...callbacks }
)
)
const body = vi.mocked(JobService.runFlowPreview).mock.calls.at(-1)?.[0].requestBody as any
expect(body.value.modules[0].value.input_transforms.user_message).toEqual({
type: 'javascript',
expr: 'flow_input.user_message'
})
expect(body.args.enabled_tools).toEqual([])
}
)
// The memory picker resets a value whose kind it does not offer to its first option, so a
// step saved with a legacy kind would run with its history switched off. And a tool name typed
// by hand that matches none silently narrows the run, so the roster is offered as in the editor.
it("test_run_step offers an aiagent step the editor's choices", async () => {
let opened: RunFormDisplay | undefined
await callGlobalTool('write_flow', {
path: 'f/flows/agent-legacy-memory',
summary: 'Agent with legacy memory',
modules: JSON.stringify([
{
id: 'ask',
value: {
type: 'aiagent',
tools: [
{
id: 'b',
summary: 'get_code',
value: {
tool_type: 'flowmodule',
type: 'rawscript',
language: 'bun',
content: 'export async function main() { return 1 }',
input_transforms: {}
}
}
],
input_transforms: {
provider: {
type: 'static',
value: { kind: 'openai', resource: '$res:u/admin/openai', model: 'gpt-4o' }
},
user_message: { type: 'static', value: 'hi' },
memory: { type: 'static', value: { kind: 'auto', context_length: 5 } },
enabled_tools: { type: 'static', value: ['get_code'] }
}
}
}
])
})
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{ path: 'f/flows/agent-legacy-memory', stepId: 'ask', args: {} },
{
...toolCallbacks,
requestRunArgs: async (_toolId, form) => {
opened = form
return form.args
}
}
)
)
const kinds = opened?.schema.properties.memory.oneOf.map(
(variant: any) => variant.properties.kind.enum[0]
)
expect(kinds).toContain('auto')
expect(opened?.schema.properties.enabled_tools.items.enum).toEqual(['get_code'])
})
// A step is fed by its input transforms, so its arguments are its own and the flow's
// schema describes a different set entirely. Opening the form on the flow's would offer
// fields this job ignores and drop the ones it takes.
@@ -8023,36 +8280,14 @@ describe('buildOpenPageUrl runs filters', () => {
})
})
describe('buildOpenPageUrl compare selection', () => {
const itemsOf = (url: string) => new URL(url, 'http://x').searchParams.get('items')
it('explicit items win over the chat mask', () => {
const url = buildOpenPageUrl(
'compare',
{ page: 'compare', items: ['script:f/a/b'] },
{ workspaceId: 'ws', chatItems: ['flow:f/c/d'] }
)
expect(itemsOf(url)).toBe('script:f/a/b')
})
it('omitted items fall back to the chat-modified mask', () => {
const url = buildOpenPageUrl(
'compare',
{ page: 'compare' },
{ workspaceId: 'ws', chatItems: ['flow:f/c/d', 'script:f/a/b'] }
)
expect(itemsOf(url)).toBe('flow:f/c/d,script:f/a/b')
})
it('an empty or absent mask yields no items param (page select-all default)', () => {
expect(
itemsOf(
buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws', chatItems: [] })
)
).toBeNull()
expect(
itemsOf(buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws' }))
).toBeNull()
describe('buildOpenPageUrl compare', () => {
// Without an explicit mode the page auto-picks, which is the draft view outside a fork.
it('always opens the fork comparison', () => {
const params = new URL(
buildOpenPageUrl('compare', { page: 'compare' }, { workspaceId: 'ws' }),
'http://x'
).searchParams
expect(params.get('mode')).toBe('fork')
})
})
@@ -8062,9 +8297,9 @@ describe('open_page workspace gating', () => {
// A workspace the user belongs to but whose `whoami` never answers.
const FLAKY = 'flaky_ws'
const openPage = () => getGlobalTool('open_page')
const pageSchema = () => (openPage().def.function.parameters as any)?.properties?.page ?? {}
let def = openPage().def
const pageSchema = () => (def.function.parameters as any)?.properties?.page ?? {}
const advertisedPages = () => (pageSchema().enum ?? []) as string[]
const pristineDef = openPage().def
beforeEach(() => {
// Admin of the workspace being browsed, plain member of the one a session operates on.
@@ -8087,16 +8322,26 @@ describe('open_page workspace gating', () => {
superadmin.set(undefined)
whoamiByWorkspace.clear()
clearWorkspaceRoleCache()
openPage().def = pristineDef
})
// Every chat shares this tool object, so a schema written back to it would reach the
// other chats' requests.
it("never writes a chat's schema back to the shared tool", async () => {
const shared = openPage().def
const snapshot = structuredClone(shared)
def = await openPage().schemaFor!({ operatingWorkspace: SESSION })
expect(def).not.toBe(shared)
expect(openPage().def).toBe(shared)
expect(shared).toEqual(snapshot)
})
// Reading the ambient `userStore` instead offers a session the pages of the workspace
// the user happens to be browsing.
it('gates on the operating workspace, not the one userStore describes', async () => {
await openPage().setSchema?.({ operatingWorkspace: NAV })
def = await openPage().schemaFor!({ operatingWorkspace: NAV })
expect(advertisedPages()).toContain('workspace_settings')
await openPage().setSchema?.({ operatingWorkspace: SESSION })
def = await openPage().schemaFor!({ operatingWorkspace: SESSION })
expect(advertisedPages()).toContain('runs')
expect(advertisedPages()).not.toContain('workspace_settings')
await expect(
@@ -8111,7 +8356,7 @@ describe('open_page workspace gating', () => {
// Neither layer may read as a denial: the role was never established, and the model
// sees the schema before it can ever reach the handler's message.
it('advertises nothing and blames no denial when the role lookup fails', async () => {
await openPage().setSchema?.({ operatingWorkspace: FLAKY })
def = await openPage().schemaFor!({ operatingWorkspace: FLAKY })
expect(advertisedPages()).toEqual([])
expect(pageSchema().description).toContain("couldn't be checked")
const refusal = await callGlobalTool('open_page', { page: 'runs' }, toolCallbacks, {
@@ -8124,7 +8369,7 @@ describe('open_page workspace gating', () => {
// A workspace absent from `userWorkspaces` is settled, not unknown: inviting a retry
// would be false, and asking `whoami` at all only earns a 401 on every iteration.
it('reports a plain denial for a workspace the user is not a member of', async () => {
await openPage().setSchema?.({ operatingWorkspace: 'unreachable_ws' })
def = await openPage().schemaFor!({ operatingWorkspace: 'unreachable_ws' })
expect(advertisedPages()).toEqual([])
expect(pageSchema().description).not.toContain("couldn't be checked")
const refusal = await callGlobalTool('open_page', { page: 'runs' }, toolCallbacks, {
@@ -8141,7 +8386,7 @@ describe('open_page workspace gating', () => {
it('asks whoami while the workspace list is still unresolved', async () => {
usersWorkspaceStore.set(undefined)
await openPage().setSchema?.({ operatingWorkspace: SESSION })
def = await openPage().schemaFor!({ operatingWorkspace: SESSION })
expect(UserService.whoami).toHaveBeenCalledWith(expect.objectContaining({ workspace: SESSION }))
expect(advertisedPages()).not.toEqual([])
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@
* on another. Plan mode's decoration is not here: it is applied per request, later.
*/
import type { ChatCompletionSystemMessageParam } from 'openai/resources/index.mjs'
import type { Tool } from '../shared'
import type { SessionTool } from '../sessionCapabilities'
import {
getSessionContextPromptSection,
globalToolsFor,
@@ -13,6 +13,7 @@ import {
} from './core'
import { createMcpTools } from './mcpTools'
import { getPipelinePromptSection, pipelineTools, type PipelineContext } from '../pipeline/core'
import { getFolderInstructionsPromptSection, type FolderInstruction } from '../folderInstructions'
// Derived, not retyped: an option added to prepareGlobalSystemMessage is reachable
// here at once. Hand-listing the four would compile fine while leaving the new one
@@ -21,6 +22,7 @@ import { getPipelinePromptSection, pipelineTools, type PipelineContext } from '.
export type GlobalAssemblyOpts = NonNullable<Parameters<typeof prepareGlobalSystemMessage>[1]> & {
sessionContext?: SessionPromptContext
pipelineContext?: PipelineContext
folderInstructions?: readonly FolderInstruction[]
}
export function assembleGlobalSystemMessage(
@@ -28,16 +30,17 @@ export function assembleGlobalSystemMessage(
opts: GlobalAssemblyOpts
): ChatCompletionSystemMessageParam {
const systemMessage = prepareGlobalSystemMessage(instructions, opts)
systemMessage.content += getFolderInstructionsPromptSection(opts.folderInstructions ?? [])
if (opts.sessionContext) {
systemMessage.content += getSessionContextPromptSection(opts.sessionContext)
systemMessage.content += getSessionContextPromptSection(opts.sessionContext, opts.access)
}
if (opts.pipelineContext) {
systemMessage.content += getPipelinePromptSection(opts.pipelineContext)
systemMessage.content += getPipelinePromptSection(opts.pipelineContext, opts.access)
}
return systemMessage
}
export function assembleGlobalTools(opts: GlobalAssemblyOpts): Tool<any>[] {
export function assembleGlobalTools(opts: GlobalAssemblyOpts): SessionTool<any>[] {
return [
...globalToolsFor({ sessionPreview: opts.previewTools ?? false }),
...(opts.pipelineContext ? pipelineTools : []),
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { ResourceService, type GetMcpToolsResponse } from '$lib/gen'
import { createToolDef, type Tool } from '../shared'
import { createToolDef } from '../shared'
import { NONE, type SessionTool } from '../sessionCapabilities'
import { enabledMcpPaths } from '$lib/components/mcp/enabledServers'
import { isOwnOrSharedMcpPath, MCP_LIST_PER_PAGE, mcpViewer } from '$lib/components/mcp/ownServers'
@@ -349,9 +350,10 @@ const callMcpToolSchema = z.object({
* mode and behind the user's confirmation — building both from one body keeps
* them from drifting.
*/
function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}> {
function createCallTool(servers: McpServer[], mode: 'read' | 'write'): SessionTool<{}> {
const isRead = mode === 'read'
return {
requires: NONE,
def: createToolDef(
callMcpToolSchema,
isRead ? 'call_mcp_read_tool' : 'call_mcp_write_tool',
@@ -419,12 +421,13 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}>
* are not registered at all, so a workspace without an MCP connection pays no
* per-iteration schema cost for them.
*/
export function createMcpTools(servers: McpServer[]): Tool<{}>[] {
export function createMcpTools(servers: McpServer[]): SessionTool<{}>[] {
if (servers.length === 0) return []
const serverList = servers.map((s) => s.path).join(', ')
return [
{
requires: NONE,
def: createToolDef(
searchMcpToolsSchema,
'search_mcp_tools',
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { whoami, deployRules } = vi.hoisted(() => ({
whoami: vi.fn(),
deployRules: vi.fn()
}))
vi.mock('$lib/gen', () => ({ UserService: { whoami } }))
vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules }))
import { resolveSessionAccess } from './sessionAccess'
import { clearWorkspaceRoleCache } from '$lib/user'
type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean }
function user(overrides: WhoamiOverrides) {
return {
email: 'u@windmill.dev',
username: 'u',
is_admin: false,
is_super_admin: false,
operator: false,
created_at: '',
disabled: false,
groups: [],
folders: [],
folders_read: [],
folders_owners: [],
...overrides
}
}
async function capabilitiesFor(overrides: WhoamiOverrides, workspace = 'ws') {
whoami.mockResolvedValueOnce(user(overrides))
return await resolveSessionAccess(workspace)
}
describe('resolveSessionAccess', () => {
beforeEach(() => {
vi.clearAllMocks()
deployRules.mockResolvedValue({ ok: true })
// The role memo is app-wide, so without this each case answers from the previous one.
clearWorkspaceRoleCache()
})
it('gives a developer every capability but admin', async () => {
const caps = await capabilitiesFor({})
expect([...caps].sort()).toEqual(['deploy', 'manage_code', 'run_preview', 'write_draft'])
})
// The folder, resource and variable handlers run the rules but have no operator check,
// so withholding `deploy` from an operator would be stricter than the server.
it('leaves an operator the deploy-rule capability, and nothing their token refuses', async () => {
const caps = await capabilitiesFor({ operator: true })
expect([...caps]).toEqual(['deploy'])
})
it('takes deploy from the rules alone, so a rule blocks an operator too', async () => {
deployRules.mockResolvedValue({ ok: false, refusedBy: 'DisableDirectDeployment' })
const caps = await capabilitiesFor({ operator: true })
expect([...caps]).toEqual([])
})
// Both spellings of `authed.is_admin`, which the draft path honours and the handlers
// refusing `authed.is_operator` do not — the session path never clears that flag.
it.each([{ is_admin: true }, { is_super_admin: true }])(
'lets an admin who is also an operator draft and deploy, but not preview or manage code (%o)',
async (role) => {
const caps = await capabilitiesFor({ ...role, operator: true })
expect([...caps].sort()).toEqual(['admin', 'deploy', 'write_draft'])
}
)
it('keeps drafting when a rule refuses deploy', async () => {
deployRules.mockResolvedValue({
ok: false,
reason: 'restricted to deployers',
refusedBy: 'RestrictDeployToDeployers'
})
const caps = await capabilitiesFor({})
expect(caps.has('deploy')).toBe(false)
expect(caps.has('write_draft')).toBe(true)
})
it('grants everything when the role cannot be resolved', async () => {
whoami.mockRejectedValueOnce(new Error('network'))
const access = await resolveSessionAccess('ws')
expect(access.has('write_draft')).toBe(true)
expect(access.has('deploy')).toBe(true)
})
})
@@ -0,0 +1,68 @@
import { getWorkspaceRole } from '$lib/user'
import { checkDeployRules } from '$lib/utils_workspace_deploy'
import type { SessionAccess, SessionCapability } from '../sessionCapabilities'
const ALL_CAPABILITIES: SessionCapability[] = [
'write_draft',
'run_preview',
'deploy',
'manage_code',
'admin'
]
/** Fail open, here and at every resolution failure below: blanking a toolset on a
* transient error tells a developer mid-session that they cannot author anything,
* which is worse and far less legible than the 403 they get by trying. Matches
* `checkDeployPermission`, which fails open for the same reason. */
export function fullSessionAccess(): SessionAccess {
return new Set(ALL_CAPABILITIES)
}
/** Pure, so the profiles this can produce are enumerable rather than listed by hand.
* `isAdmin` is one field because the server's `authed.is_admin` is
* `usr.is_admin || super_admin` (auth.rs), which `whoami` reports as two. */
export function capabilitiesForRole(role: {
isAdmin: boolean
operator: boolean
deployRulesPass: boolean
}): SessionAccess {
const capabilities = new Set<SessionCapability>()
// Per-capability precedence, NOT a role ladder: drafts.rs `require_can_write_path`
// returns Ok on `authed.is_admin` BEFORE its operator branch, while jobs.rs
// `run_preview_*` and the script/flow/app handlers refuse `authed.is_operator` with no
// admin escape — and on the session path that flag is never cleared for an admin.
if (role.isAdmin || !role.operator) {
capabilities.add('write_draft')
}
if (!role.operator) {
capabilities.add('run_preview')
capabilities.add('manage_code')
}
// No operator term: the rules are their own gate, and the handlers that also refuse
// operators say so through `manage_code`. Admins bypass the rules inside the check.
if (role.deployRulesPass) {
capabilities.add('deploy')
}
if (role.isAdmin) {
capabilities.add('admin')
}
return capabilities
}
export async function resolveSessionAccess(workspace: string): Promise<SessionAccess> {
// Shares the 5-minute memo with the identity the same pre-flight resolves beside this
// one, so a send costs one `whoami` rather than two, at the cost of a role changed
// elsewhere landing within that window rather than on the very next message.
// `lookup_failed` covers a rejected request AND a body too malformed to map.
const lookup = await getWorkspaceRole(workspace)
if (lookup.kind !== 'resolved') {
return fullSessionAccess()
}
const me = lookup.user
return capabilitiesForRole({
isAdmin: !!me.is_admin || !!me.is_super_admin,
operator: !!me.operator,
deployRulesPass: (await checkDeployRules(workspace, me)).ok
})
}
@@ -0,0 +1,219 @@
import { describe, expect, it, vi } from 'vitest'
// The toolset pulls in the script/flow editor tools, hence monaco. Same stand-ins as
// global/core.test.ts; nothing here executes a tool, so bare shapes are enough.
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', () => ({}))
// `globalToolsFor` withholds take_screenshot off Blink, and node reports a `Node.js/<v>`
// user agent — so without this the description sweep below silently skips a tool that
// ships in every real Chromium session. Claim the maximal toolset instead.
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 Chrome/120.0.0.0' })
import { globalTools, prepareGlobalSystemMessage, type SessionPromptContext } from './core'
import { appendPlanModeInstructions } from '../planMode'
import { PlanModeController } from '../planModeController.svelte'
import { pipelineTools } from '../pipeline/core'
import { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssembly'
import { filterSessionTools, sessionToolAllowed } from './sessionToolset'
import { capabilitiesForRole, fullSessionAccess } from './sessionAccess'
import type { SessionAccess, SessionCapability, SessionTool } from '../sessionCapabilities'
const ASSEMBLY_OPTS = {
previewTools: true,
pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] },
mcpServers: [{ path: 'f/test/server' } as any]
}
/** Every tool that can reach a session toolset: what assembly builds, so a source added
* there needs no edit here, unioned with the static sources so the set cannot shrink if
* assembly stops reaching one, plus the plan tools the manager appends. */
function sessionReachableTools(): SessionTool<any>[] {
const plan = new PlanModeController({ available: true } as any).availableTools
const byName = new Map<string, SessionTool<any>>()
for (const t of [
...assembleGlobalTools(ASSEMBLY_OPTS),
...globalTools,
...pipelineTools,
...plan
]) {
byName.set(t.def.function.name, t)
}
return [...byName.values()]
}
function withheldNames(access: SessionAccess): string[] {
return sessionReachableTools()
.filter((t) => !sessionToolAllowed(t, access))
.map((t) => t.def.function.name)
}
/** The tools a session actually ships, through the same assembly production uses. */
function shippedSessionTools(access: SessionAccess) {
return filterSessionTools(assembleGlobalTools(ASSEMBLY_OPTS), access)
}
function accessWith(capabilities: SessionCapability[]): SessionAccess {
return new Set(capabilities)
}
/** The profiles a real session can hold, keyed for the test name, over every combination
* of the role facts `capabilitiesForRole` reads. */
const REACHABLE_PROFILES = [
...new Map(
[false, true].flatMap((isAdmin) =>
[false, true].flatMap((operator) =>
// `checkDeployRules` bypasses on admin, so an admin has only the one outcome.
(isAdmin ? [true] : [false, true]).map((deployRulesPass) => {
const access = capabilitiesForRole({ isAdmin, operator, deployRulesPass })
return [[...access].sort().join(',') || 'none', access] as const
})
)
)
).entries()
]
/** One per branch of getSessionContextPromptSection — each words the deploy target
* differently, so a gate fixed in one branch can still leak in another. */
const SESSION_CONTEXTS: SessionPromptContext[] = [
{ pendingForkOf: 'parent' },
{ workspaceId: 'dev', parentWorkspaceId: 'parent', isDevWorkspace: true },
{ workspaceId: 'fork', parentWorkspaceId: 'parent' },
{ workspaceId: 'fork', forkParentUnknown: true },
{ workspaceId: 'live' },
{}
]
describe('session tool policies', () => {
// Full access must be a no-op, or every existing session (and the ai_evals
// baseline measured against it) changes behaviour.
it('withholds nothing from a session with every capability', () => {
const tools = sessionReachableTools()
const filtered = filterSessionTools(tools, fullSessionAccess())
expect(filtered).toHaveLength(tools.length)
filtered.forEach((tool, i) => expect(tool).toBe(tools[i]))
})
it('withholds a tool that arrives without a policy', () => {
expect(sessionToolAllowed({ def: globalTools[0].def }, fullSessionAccess())).toBe(false)
})
it('passes the toolset through untouched when access is unresolved', () => {
const tools = globalTools.map((t) => ({ def: t.def }))
expect(filterSessionTools(tools, undefined)).toBe(tools)
})
// The kind is an argument, so its enum carries the permission, per the handler each kind
// reaches. Asserted on the shipped schema, since that is all the model sees.
it('offers the kind-taking tools only the kinds their handlers accept', () => {
const shipped = (name: string, access: SessionAccess) =>
shippedSessionTools(access).find((t) => t.def.function.name === name)!
const kindsOf = (name: string, access: SessionAccess) =>
(shipped(name, access).def.function.parameters as any).properties.type.enum
// A developer a protection rule refuses keeps the two kinds no rule reaches.
const refused = accessWith(['write_draft', 'run_preview', 'manage_code'])
expect(kindsOf('deploy_workspace_item', refused)).toEqual(['schedule', 'trigger'])
expect(kindsOf('delete_workspace_item', refused)).toEqual(['schedule', 'trigger'])
// An operator where no rule applies: only the code handlers refuse them.
const operator = accessWith(['deploy'])
expect(kindsOf('deploy_workspace_item', operator)).toEqual([
'schedule',
'trigger',
'resource',
'variable'
])
// Deleting a script is admin-only, where deploying one is not.
const developer = accessWith(['write_draft', 'run_preview', 'manage_code', 'deploy'])
expect(kindsOf('deploy_workspace_item', developer)).toContain('script')
expect(kindsOf('delete_workspace_item', developer)).not.toContain('script')
// Full access narrows nothing and ships the shared object itself, so the tool defs —
// part of every iteration's cached prefix — are byte for byte what they were.
const original = globalTools.find((t) => t.def.function.name === 'deploy_workspace_item')!
expect(shipped('deploy_workspace_item', fullSessionAccess())).toBe(original)
// And narrowing never writes through to it: the def is shared by every session, so a
// mutation here would strip one user's kinds from everyone else's schema.
expect((original.def.function.parameters as any).properties.type.enum).toContain('script')
})
// Naming a tool the model was not given produces invented calls. Asserted over the
// ASSEMBLED message: the session-state, pipeline and plan-mode sections are each
// appended by a different caller, and each can name a withheld tool.
it.each(REACHABLE_PROFILES)(
'never names a withheld tool in the assembled prompt (%s)',
(_label, access) => {
const withheld = withheldNames(access)
// The tool DEFINITIONS ship alongside the prompt, so a withheld name in a
// description is the same broken promise as one in the prompt.
const defs = JSON.stringify(shippedSessionTools(access).map((t) => t.def))
expect(withheld.filter((n) => defs.includes(n))).toEqual([])
for (const previewTools of [false, true]) {
for (const ctx of SESSION_CONTEXTS) {
const msg = assembleGlobalSystemMessage(undefined, {
previewTools,
user: { username: 'alex', folders: ['shared'], folders_read: ['shared'] },
access,
sessionContext: ctx,
pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] }
})
// Both decoration variants: the escalation one adds its own tool mentions.
for (const blocks of [0, 9]) {
const full = appendPlanModeInstructions(msg, blocks).content as string
expect(withheld.filter((n) => full.includes(n))).toEqual([])
}
}
}
}
)
// A tool result is the one place the sweep above cannot see, and `get_instructions`
// ships to every profile with guidance written around the draft tools by name.
it('does not hand authoring guidance naming withheld tools to a session that cannot draft', async () => {
const tool = globalTools.find((t) => t.def.function.name === 'get_instructions')!
const call = (access: SessionAccess) =>
tool.fn({
args: { subject: 'script', language: 'bun' },
workspace: 'ws',
helpers: { access },
toolId: 't1',
toolCallbacks: { setToolStatus: () => {} }
} as any) as Promise<string>
const readOnly = accessWith([])
const withheld = withheldNames(readOnly)
const restricted = await call(readOnly)
expect(withheld.filter((n) => restricted.includes(n))).toEqual([])
// The gate is the whole test, so pin that it is not simply refusing everyone.
expect(await call(fullSessionAccess())).toContain('write_script')
})
// A full-access profile must gate nothing at all: the text has to match the ungated
// build byte for byte, or every session's cached prefix and the ai_evals baseline
// move underneath us.
it('builds an unchanged prompt when every capability is present', () => {
const user = { username: 'alex', folders: ['shared'], folders_read: ['shared'] }
for (const previewTools of [false, true]) {
const ungated = prepareGlobalSystemMessage(undefined, { previewTools, user }).content
const full = prepareGlobalSystemMessage(undefined, {
previewTools,
user,
access: fullSessionAccess()
}).content
expect(full).toBe(ungated)
}
})
})
@@ -0,0 +1,46 @@
import type { ChatCompletionFunctionTool } from 'openai/resources/index.mjs'
import type { SessionAccess, SessionToolPolicy } from '../sessionCapabilities'
type FilterableTool = {
def: ChatCompletionFunctionTool
requires?: SessionToolPolicy
kindRequires?: Readonly<Partial<Record<string, SessionToolPolicy>>>
}
const satisfies = (policy: SessionToolPolicy, access: SessionAccess) =>
policy.every((c) => access.has(c))
/** Fails closed: every array that feeds a session is typed to declare `requires`, so a tool
* without one arrived some other way, and leaking it is the worse of the two answers. */
export function sessionToolAllowed(tool: FilterableTool, access: SessionAccess): boolean {
return tool.requires ? satisfies(tool.requires, access) : false
}
/** Filter an assembled toolset, and cut each kind-taking tool's `type` enum to the kinds
* `access` can land. `access` undefined means "not resolved yet, or not a session" — the
* toolset passes through untouched. */
export function filterSessionTools<T extends FilterableTool>(
tools: T[],
access: SessionAccess | undefined
): T[] {
if (!access) return tools
return tools.filter((t) => sessionToolAllowed(t, access)).map((t) => narrowKinds(t, access))
}
/** Returns the tool itself when every kind is allowed, so full access ships the same bytes:
* tool defs are part of every iteration's cached prefix. Builds new objects otherwise — the
* defs are module singletons shared by every session. */
function narrowKinds<T extends FilterableTool>(tool: T, access: SessionAccess): T {
const kindRequires = tool.kindRequires
if (!kindRequires) return tool
const parameters = tool.def.function.parameters as {
properties: Record<string, { enum?: string[] }>
}
const kinds = parameters.properties.type.enum ?? []
// A kind with no entry is withheld, as a tool with no `requires` is.
const allowed = kinds.filter((k) => kindRequires[k] && satisfies(kindRequires[k], access))
if (allowed.length === kinds.length) return tool
const def = structuredClone(tool.def)
;(def.function.parameters as typeof parameters).properties.type.enum = allowed
return { ...tool, def }
}
@@ -601,7 +601,8 @@ export async function parseOpenAIResponsesCompletion(
toolCall,
helpers,
toolCallbacks: callbacks,
workspace: options?.workspace
workspace: options?.workspace,
messages
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
@@ -1,7 +1,14 @@
import { z } from 'zod'
import { $ScriptLang } from '$lib/gen/schemas.gen'
import type { ScriptLang } from '$lib/gen'
import { createToolDef, executeTestRun, findAndReplace, type Tool } from '../shared'
import { createToolDef, executeTestRun, findAndReplace } from '../shared'
import {
NONE,
RUN_PREVIEW,
WRITE_DRAFT,
type SessionAccess,
type SessionTool
} from '../sessionCapabilities'
import type { PipelineOutputKind } from '$lib/components/assets/AssetGraph/pipelineTemplates'
// ============================================================================
@@ -113,7 +120,7 @@ const readPipelineNodeSchema = z.object({
const readPipelineNodeToolDef = createToolDef(
readPipelineNodeSchema,
'read_pipeline_node',
'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before edit_pipeline_node so edits target the exact current text.'
'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before editing a node so edits target the exact current text.'
)
// ----------------------------------------------------------------------------
@@ -205,8 +212,9 @@ function inferredLineageNote(reads: string[], writes: string[]): string {
return ` Inferred lineage: ${parts.join('; ')}.`
}
export const pipelineTools: Tool<PipelineToolHelpers>[] = [
export const pipelineTools: SessionTool<PipelineToolHelpers>[] = [
{
requires: NONE,
def: getPipelineGraphToolDef,
planModeSafe: true,
fn: async ({ helpers, toolId, toolCallbacks }) => {
@@ -221,6 +229,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: NONE,
def: readPipelineNodeToolDef,
planModeSafe: true,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
@@ -236,6 +245,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: WRITE_DRAFT,
def: buildPipelineNodeToolDef,
streamArguments: true,
showDetails: true,
@@ -258,6 +268,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: WRITE_DRAFT,
def: editPipelineNodeToolDef,
streamArguments: true,
showDetails: true,
@@ -286,6 +297,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: WRITE_DRAFT,
def: removePipelineNodeToolDef,
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
const pipeline = requirePipeline(helpers)
@@ -300,6 +312,7 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
}
},
{
requires: RUN_PREVIEW,
def: testPipelineNodeToolDef,
requiresConfirmation: true,
confirmationMessage: 'Run pipeline node',
@@ -331,8 +344,14 @@ export const pipelineTools: Tool<PipelineToolHelpers>[] = [
* /pipeline editor is open. Describes the annotation model and the direct-draft
* workflow so the model uses the pipeline tools rather than the generic
* write_script draft tools.
*
* `access` is the session's resolved capabilities (undefined outside a session, or
* before they resolve). The node-authoring guidance is dropped without `write_draft`,
* since the tools it names are withheld from that session; the annotation model stays,
* as it is what lets the model read and explain an existing pipeline.
*/
export function getPipelinePromptSection(ctx: PipelineContext): string {
export function getPipelinePromptSection(ctx: PipelineContext, access?: SessionAccess): string {
const canWriteDraft = !access || access.has('write_draft')
return `
Data Pipeline editor (ACTIVE):
@@ -342,8 +361,12 @@ Data Pipeline editor (ACTIVE):
- \`materialize\` (the managed output): a managed \`// materialize ducklake://<name>/<table>\` means the runtime writes the node's output table FOR you — write the body as a single SELECT and the runtime wraps it in the create/replace, so do NOT also write your own CREATE TABLE / INSERT. The \`dbt://\` target below is the opposite: the node writes its own DDL and none of the write strategies apply to it. IMPORTANT: a MANAGED \`// materialize\` is **DuckDB-only** and its target MUST be a DuckLake table (\`ducklake://<name>/<table>\`) — deploy rejects a \`ducklake://\` target on any other language. For a \`python3\`/\`bun\`/\`postgresql\` node writing the lake, do NOT use \`// materialize\`; write the output via the SDK instead (e.g. \`wmill.writeS3File(...)\`, a \`CREATE TABLE\` in postgresql, or \`wmill.databaseUrlFromResource\`/ducklake helpers) and let the output be inferred. Reach for \`duckdb\` when a node should materialize a DuckLake table. The one target any language BUT DBT'S OWN may declare (a dbt project's writes come from its manifest, so \`// materialize\` on a dbt script is rejected at deploy) is a WAREHOUSE RELATION: \`// materialize manual dbt://<warehouse>/<schema>/<name>\`, with \`<warehouse>\` a warehouse the workspace configures under Settings → dbt. \`manual\` is its only mode — nothing generates warehouse DDL, so the node issues its own write and the annotation records the outcome. Use it on an ingestion node a dbt project reads as a \`source\`: the declared relation and the dbt model become ONE graph node, and a downstream \`// on dbt://<warehouse>/<schema>/<name>\` fires when that node completes. Write strategy: with no option it REPLACES the whole table each run (full refresh; the only mode whose output columns may change); \`// materialize <uri> append\` INSERT-appends rows (incremental); \`// materialize <uri> key=<col>\` merges/upserts on \`<col>\`. \`// materialize manual <uri>\` opts OUT of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. \`materialize\` is paired with partitioning for incremental pipelines: a \`// partitioned <daily|hourly|weekly|monthly|dynamic>\` node runs once per partition (append/merge into a fixed-schema table), and the \`{partition}\` token — usable in any asset URI AND in the body SQL — is substituted with the current partition's IDENTITY string at run time. To filter the source to the active slice on a time grain, use the runtime-injected macro: \`WHERE wm_partition(<ts_col>) = {partition}\`. \`wm_partition(ts)\` buckets a timestamp with the exact identity format the runtime used (daily/hourly/weekly/monthly), so it always matches and you never hand-write a \`strftime\` format. Do NOT write \`= TIMESTAMP {partition}\`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at runtime. For \`dynamic\` partitioning the identity is your caller-supplied key (not a timestamp, no macro), so filter on it directly: \`WHERE <your_key_col> = {partition}\`. \`materialize\` is an output DECLARATION on the node — it is not a command; there is no "materialize run".
- \`measure\` / \`dimension\` (declared metrics): on a node that materializes a DuckLake table, \`// measure <name> = <aggregate> [where <predicate>]\` names the canonical way to aggregate that table (e.g. \`// measure revenue = sum(amount) where not is_refund\`), and \`// dimension <name> = <expr>\` names a way to slice it (e.g. \`// dimension region = region\`, \`// dimension month = date_trunc('month', ordered_at)\`). They execute nothing: they are catalogued at deploy so the editor and other agents can reuse the definition instead of re-deriving it and silently disagreeing. Keep the predicate in the \`where\` clause rather than folding it into the aggregate: it is rendered as \`<agg> FILTER (WHERE <pred>)\`, which is what lets two measures with different predicates sit under one GROUP BY. DuckLake-only, and only meaningful next to \`// materialize\`. Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures, an obvious \`count(*)\` earns nothing. To USE a metric another node declares, read that node with read_pipeline_node and reuse its exact expression rather than guessing it.
- Use get_pipeline_graph to see the current nodes/assets/triggers, and read_pipeline_node before editing one.
- Every node of this pipeline lives at \`f/${ctx.folder}/<node_name>\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`<node_name>\`).
- Every node of this pipeline lives at \`f/${ctx.folder}/<node_name>\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`<node_name>\`).${
canWriteDraft
? `
- Build new nodes with build_pipeline_node and edit existing ones with edit_pipeline_node. These apply directly as unsaved drafts on the canvas (like the flow/script editor applies AI edits) — they DO NOT deploy. There is no separate Accept/Reject step. Prefer these over the generic write_script/edit_script draft tools while a pipeline is open.
- Reuse existing asset paths from the graph when wiring a downstream node to an upstream one (read the upstream's write asset, then \`// on\` that same URI).
- Only deploy when the user explicitly asks; the user deploys drafts from the canvas.`
: ''
}`
}
@@ -1,6 +1,7 @@
import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
import { createToolDef, type Tool, type ToolCallbacks } from './shared'
import { createToolDef, type ToolCallbacks } from './shared'
import { NONE, type SessionTool } from './sessionCapabilities'
import {
appendPlanModeInstructions,
derivePlanTitle,
@@ -100,14 +101,24 @@ export class PlanModeController {
/** Only the transition the current posture allows; auto-accepting exposes neither, since
* entering is the user's choice. */
get tools(): Tool<any>[] {
get tools(): SessionTool<any>[] {
if (!this.#host.available) return []
if (this.#host.autoAccepting) return []
return this.#host.active ? [this.exitTool] : [this.enterTool]
}
/** Both transitions, whichever posture is selected: what plan mode contributes to this
* chat's capabilities, rather than to the turn it is about to send. */
get availableTools(): SessionTool<any>[] {
return this.#host.available ? [this.enterTool, this.exitTool] : []
}
// This safety tag is what keeps plan mode escapable through its handoff tool.
exitTool: Tool<any> = {
exitTool: SessionTool<any> = {
// Ungated even for a user who can change nothing: the posture is the USER's choice,
// and its instructions order this call to hand the plan over — withholding it
// strands the model in a round nothing else can end.
requires: NONE,
def: createToolDef(exitPlanModeArgs, EXIT_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL_DESCRIPTION),
planModeSafe: true,
requiresConfirmation: true,
@@ -156,7 +167,8 @@ export class PlanModeController {
}
}
enterTool: Tool<any> = {
enterTool: SessionTool<any> = {
requires: NONE,
def: createToolDef(enterPlanModeArgs, ENTER_PLAN_MODE_TOOL, ENTER_PLAN_MODE_TOOL_DESCRIPTION),
planModeSafe: true,
requiresConfirmation: true,
@@ -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'
}
})
})
})
@@ -9,6 +9,7 @@ import type {
} from 'openai/resources/index.mjs'
import { type DBSchema, dbSchemas } from '$lib/stores'
import type { ContextElement } from '../context'
import { NONE, RUN_PREVIEW, type SessionTool } from '../sessionCapabilities'
import {
createSearchHubScriptsTool,
type Tool,
@@ -27,6 +28,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
@@ -461,9 +463,12 @@ export const resourceTypeTool: Tool<ScriptChatHelpers> = {
// Generic DB schema tool factory shared by the script, flow and global modes
export function createDbSchemaTool<T>(
opts: { description?: string; updateEditorCache?: boolean } = {}
): Tool<T> {
): SessionTool<T> {
const { description, updateEditorCache = true } = opts
return {
// `getDbSchemas` below introspects by running a query job through /jobs/run/preview,
// which jobs.rs refuses operators — so this reads like a lookup but gates like a run.
requires: RUN_PREVIEW,
def: description
? {
...DB_SCHEMA_FUNCTION_DEF,
@@ -609,7 +614,8 @@ const SEARCH_NPM_PACKAGES_TOOL: ChatCompletionFunctionTool = {
}
// Helpers-agnostic so both script mode and global mode can offer it.
export const searchNpmPackagesTool: Tool<{}> = {
export const searchNpmPackagesTool: SessionTool<{}> = {
requires: NONE,
def: SEARCH_NPM_PACKAGES_TOOL,
planModeSafe: true,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -826,7 +832,12 @@ export const editCodeToolWithDiff: Tool<ScriptChatHelpers> = {
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 +889,12 @@ export const editCodeTool: Tool<ScriptChatHelpers> = {
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) {
@@ -0,0 +1,52 @@
import type { Tool } from './shared'
// A leaf on purpose: every module defining a session-reachable tool imports these
// constants, so importing anything with side effects here drags it into all of them.
/**
* What a user may do in ONE workspace, as an AI session's toolset needs to know it.
* Best-effort, not a boundary — the token is the enforcement point, so this narrows
* what the model is offered and guarantees nothing.
*/
export type SessionCapability =
/** Passes drafts.rs `require_can_write_path` — for a draft of any kind, schedules and
* triggers included, though an operator may create those directly. */
| 'write_draft'
/** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */
| 'run_preview'
/** Passes `check_deploy_rules`. */
| 'deploy'
/** Passes the operator refusal the script, flow and app handlers make before creating or
* deleting one. The same condition as `run_preview` today, but another handler family's
* check, so each follows its own gate if the two ever diverge. */
| 'manage_code'
/** Passes `require_admin`. */
| 'admin'
export type SessionAccess = ReadonlySet<SessionCapability>
/**
* The capabilities without which a tool's call cannot succeed — usually because the
* backend refuses it, occasionally because only that capability produces the tool's
* input. Never a relevance judgement: a tool the server would accept ships, even when
* it is of little use to the session, because withholding it is a stricter answer than
* the one the user would get by trying.
*/
export type SessionToolPolicy = readonly SessionCapability[]
export const NONE: SessionToolPolicy = []
export const WRITE_DRAFT: SessionToolPolicy = ['write_draft']
export const RUN_PREVIEW: SessionToolPolicy = ['run_preview']
export const DEPLOY: SessionToolPolicy = ['deploy']
/**
* A tool that can reach an AI session's toolset. `requires` is mandatory, and declared
* beside the `fn` whose call is what proves it right.
*/
export type SessionTool<T> = Tool<T> & {
requires: SessionToolPolicy
/** For a tool taking the item kind as its `type` argument, where one verdict for the whole
* tool is either too strict or too loose: what each kind's handler needs. A session is
* offered only the kinds it can land, and the rest never appear in the schema it sees. */
kindRequires?: Readonly<Partial<Record<string, SessionToolPolicy>>>
}
@@ -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: {}
@@ -54,7 +54,10 @@ vi.mock('$lib/gen', () => ({
AzureTriggerService: { createAzureTrigger: vi.fn() },
AmqpTriggerService: { createAmqpTrigger: vi.fn() },
EmailTriggerService: { createEmailTrigger: vi.fn() },
SettingService: { getGlobal: vi.fn() }
SettingService: { getGlobal: vi.fn() },
ResourceService: {
getResourceValue: vi.fn(async ({ path }: { path: string }) => ({ content: `body of ${path}` }))
}
}))
vi.mock('$lib/utils', () => ({
@@ -205,6 +208,106 @@ describe('buildContextString', () => {
})
describe('processToolCall', () => {
it('delivers folder instructions once, holding back a change until they are read', async () => {
const { createToolDef, processToolCall } = await import('./shared')
const read = vi.fn(async () => 'read ok')
const write = vi.fn(async () => 'write ok')
const tools = [
{ def: createToolDef(z.object({}), 'read_item', 'Read'), planModeSafe: true, fn: read },
{ def: createToolDef(z.object({}), 'write_item', 'Write'), fn: write }
]
const folderInstructions = {
list: () => [
{ path: 'f/billing/AGENTS', scope: 'f/billing/' },
{ path: 'f/billing/eu/AGENTS', scope: 'f/billing/eu/' },
{ path: 'f/other/AGENTS', scope: 'f/other/' },
{ path: 'g/ops/AGENTS', scope: 'g/ops/' }
],
deliveredBy: new Map<string, { workspace: string; paths: readonly string[] }>()
}
const messages: any[] = []
const call = async (
id: string,
name: string,
path: string | object,
workspace = 'test-workspace'
) => {
const message = await processToolCall({
tools,
toolCall: {
id,
type: 'function',
function: {
name,
arguments: JSON.stringify(typeof path === 'string' ? { path } : path)
}
},
helpers: {},
workspace,
messages,
toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn(), folderInstructions }
})
messages.push(message)
return message.content as string
}
const turn = () => messages.push({ role: 'assistant', content: '' })
turn()
const readResult = await call('c1', 'read_item', 'f/billing/eu/invoice')
expect(read).toHaveBeenCalledTimes(1)
expect(readResult.startsWith('read ok')).toBe(true)
// Outermost first, so the nested folder's instructions read as the specific ones.
expect(readResult.indexOf('body of f/billing/AGENTS')).toBeLessThan(
readResult.indexOf('body of f/billing/eu/AGENTS')
)
// Same batch: the model has not read c1's result yet, so the write still waits,
// pointed at that result rather than handed the bodies twice.
const sameBatch = await call('c2', 'write_item', 'f/billing/eu/invoice')
expect(write).not.toHaveBeenCalled()
expect(sameBatch).not.toContain('body of f/billing/AGENTS')
expect(sameBatch).toContain('earlier result of this same batch')
turn()
expect(await call('c3', 'write_item', 'f/billing/eu/invoice')).toBe('write ok')
const held = await call('c4', 'write_item', 'f/other/x')
expect(write).toHaveBeenCalledTimes(1)
expect(held).toContain('body of f/other/AGENTS')
expect(held).not.toContain('body of f/billing/AGENTS')
turn()
expect(await call('c5', 'write_item', 'f/other/x')).toBe('write ok')
expect(await call('g1', 'read_item', 'g/ops/runbook')).toContain('body of g/ops/AGENTS')
// The same path in another workspace is another resource, delivered afresh.
expect(await call('w1', 'read_item', 'f/other/x', 'other-workspace')).toContain(
'body of f/other/AGENTS'
)
// A delivery lost from the conversation (compaction) is made again.
messages.splice(0)
turn()
expect(await call('c6', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS')
// A trigger names its target inside its config.
messages.splice(0)
turn()
const nested = await call('t1', 'write_item', { kind: 'http', config: { path: 'f/other/t' } })
expect(nested).toContain('body of f/other/AGENTS')
expect(write).toHaveBeenCalledTimes(2)
// A call stopped mid-run is answered with a placeholder under its id: no delivery.
messages.splice(0)
folderInstructions.deliveredBy.set('stopped', {
workspace: 'test-workspace',
paths: ['f/billing/AGENTS']
})
messages.push({ role: 'tool', tool_call_id: 'stopped', content: 'Interrupted' })
turn()
expect(await call('c7', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS')
})
it('returns pre-confirmation validation errors without asking for confirmation', async () => {
const { createToolDef, processToolCall } = await import('./shared')
const error = 'the script needs to be deployed before doing this action'
@@ -1869,3 +1972,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()
})
})
@@ -7,6 +7,7 @@ import type { UserDraftItemKind } from '$lib/gen'
// The gate's two refusals, from a module that holds prose and one size limit: under the
// shallow-import rule below, the rest of plan mode is not reachable from here.
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import { NONE } from './sessionCapabilities'
// Import-free leaf, so it satisfies the shallow-import rule below.
import {
openItemPreviewAction,
@@ -38,6 +39,7 @@ import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } fro
import { workspaceStore } from '$lib/stores'
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree'
import { agentTestInputTransforms } from '$lib/components/flows/agentFormFields'
import type { FunctionParameters } from 'openai/resources/shared.mjs'
import { z } from 'zod'
import {
@@ -59,6 +61,7 @@ import { forLater } from '$lib/forLater'
import { scriptLangToEditorLang } from '$lib/scripts'
import { getCurrentModel } from '$lib/aiStore'
import { type editor as meditor } from 'monaco-editor'
import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions'
// Prettify function for code arguments - extracts and formats code from JSON
function prettifyCodeArguments(content: string): string {
@@ -610,6 +613,84 @@ export type WebSearchSource = {
title?: string
}
export type ToolCodeDiff = {
before: string
after: string
/** Monaco language id. */
lang: string
}
/** The result shape any tool returns to have it rendered as a web search card. */
export type WebSearchResult = {
sources: WebSearchSource[]
query?: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** A link the card can render: the source list drops everything else, so a result whose
* urls are relative or `javascript:` would show as an empty list in place of its own output. */
export function isRenderableSourceUrl(url: string): boolean {
try {
return ['http:', 'https:'].includes(new URL(url).protocol)
} catch {
return false
}
}
/** An absent optional field reaches JSON as `null` from a Python tool and as nothing from a
* TypeScript one, so both read as absent. */
function isOptionalString(value: unknown): boolean {
return value === null || typeof value === 'string'
}
function isWebSearchSource(value: unknown): value is { url: string; title?: unknown } {
return (
isRecord(value) &&
typeof value.url === 'string' &&
isRenderableSourceUrl(value.url) &&
Object.entries(value).every(
([key, v]) => key === 'url' || (key === 'title' && isOptionalString(v))
)
)
}
/**
* A tool result as a web search, or undefined when it is anything else. The keys must be
* exactly `sources` and an optional `query`: the card renders only urls and titles, so a
* result carrying more would lose it. Accepts the result as its JSON text too.
*/
export function webSearchResultOf(result: unknown): WebSearchResult | undefined {
if (typeof result === 'string') {
try {
result = JSON.parse(result)
} catch {
return undefined
}
}
if (!isRecord(result) || !Array.isArray(result.sources) || result.sources.length === 0) {
return undefined
}
const sources = result.sources
if (
!sources.every(isWebSearchSource) ||
!Object.entries(result).every(
([key, v]) => key === 'sources' || (key === 'query' && isOptionalString(v))
)
) {
return undefined
}
return {
sources: sources.map((source) => ({
url: source.url,
title: typeof source.title === 'string' ? source.title : undefined
})),
query: typeof result.query === 'string' ? result.query : undefined
}
}
export type ToolDisplayMessage = {
role: 'tool'
tool_call_id: string
@@ -654,6 +735,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. */
@@ -661,6 +745,8 @@ export type ToolDisplayMessage = {
/** Refused by the plan-mode gate. Renders as its own lean row rather than a tool
* error, so the transcript says the mode stopped it and not that the call failed. */
blockedByPlanMode?: boolean
/** Held back until the model has read the folder instructions it was handed. */
heldForFolderInstructions?: boolean
/** The user declined: the reject button, a Stop, or a posture switch. Set only there, so
* a decision is distinguishable from every other way a call errors. */
declinedByUser?: boolean
@@ -894,20 +980,29 @@ function stringifyErrorBody(body: unknown): string {
* (tab closed while a tool polls) logs nothing, so the statuses sum to the calls that
* finished, not to the calls made.
*/
type ToolCallStatus = 'ok' | 'error' | 'declined' | 'rejected' | 'blocked_plan_mode'
type ToolCallStatus =
| 'ok'
| 'error'
| 'declined'
| 'rejected'
| 'blocked_plan_mode'
| 'held_for_instructions'
export async function processToolCall<T>({
tools,
toolCall,
helpers,
toolCallbacks,
workspace
workspace,
messages = []
}: {
tools: Tool<T>[]
toolCall: ChatCompletionMessageFunctionToolCall
helpers: T
toolCallbacks: ToolCallbacks
workspace?: string
/** The conversation so far, for which folder instructions it already carries. */
messages?: readonly ChatCompletionMessageParam[]
}): Promise<ChatCompletionMessageParam> {
const tool = tools.find((t) => t.def.function.name === toolCall.function.name)
const workspaceId = workspace ?? get(workspaceStore) ?? ''
@@ -991,6 +1086,43 @@ export async function processToolCall<T>({
}
}
// After the gates that refuse the call, so a refused call does not use up the
// delivery. A call that changes something is held back until the model has read
// the instructions: arriving with its result, they would be too late to shape it.
const folderInstructions = tool
? await pendingFolderInstructions(
toolCallbacks.folderInstructions,
messages,
args,
workspaceId
)
: undefined
if (folderInstructions?.paths.length) {
toolCallbacks.folderInstructions?.deliveredBy.set(toolCall.id, {
workspace: workspaceId,
paths: folderInstructions.paths
})
}
if (folderInstructions && tool?.planModeSafe !== true) {
logToolOutcome('held_for_instructions')
toolCallbacks.setToolStatus(toolCall.id, {
content: 'Read folder instructions',
heldForFolderInstructions: true,
parameters: args,
isLoading: false,
isQueued: false,
isStreamingArguments: false,
needsConfirmation: false,
showDetails: tool?.showDetails,
autoCollapseDetails: tool?.autoCollapseDetails
})
return {
role: 'tool' as const,
tool_call_id: toolCall.id,
content: `Not run: this call touches a folder whose instructions you had not read yet. Follow them, then make the call again (adjusted if they require it).\n\n${folderInstructions.text}`
}
}
const requiresConfirmation = tool?.requiresConfirmation === true
// By name: skipping the wait is itself an answer on the user's behalf, and one tool
// must not be answered for.
@@ -1095,7 +1227,7 @@ export async function processToolCall<T>({
const toAdd = {
role: 'tool' as const,
tool_call_id: toolCall.id,
content: result
content: folderInstructions ? `${result}\n\n${folderInstructions.text}` : result
}
return toAdd
} catch (err) {
@@ -1191,7 +1323,10 @@ export interface Tool<T> {
workspace: string
helpers: T
}) => MaybePromise<ToolRejection | undefined>
setSchema?: (helpers: any) => Promise<void>
/** This chat's definition of the tool, for a schema that depends on the chat (its workspace,
* its open flow). Returns a new def: tools are module singletons shared by every chat, so
* one written back leaks into the others. */
schemaFor?: (helpers: any) => Promise<ChatCompletionFunctionTool>
/** Safe to run while plan mode is active. Absence fails closed. */
planModeSafe?: boolean
/** The arguments a plan-mode-safe tool still refuses while the posture holds — for a tool
@@ -1392,6 +1527,9 @@ export interface ToolCallbacks {
attachToolImage?: (toolId: string, image: AttachedImage) => void
/** Drain every image buffered this batch (insertion order), clearing the buffer. */
takePendingToolImages?: () => AttachedImage[]
/** Wired only by the global/sessions chat: delivers the folder instructions
* covering the paths a tool call names, once per conversation. */
folderInstructions?: FolderInstructionsContext
}
export function createToolDef(
@@ -1491,6 +1629,7 @@ export function isHubPath(path: string): boolean {
}
export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
requires: NONE,
def: searchHubScriptsToolDef,
planModeSafe: true,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -2097,6 +2236,13 @@ type FlowStepRunConfig = {
toolId: string
loadScript?: FlowStepScriptLoader
loadSubflow?: FlowStepSubflowLoader
/** The path of the flow the step belongs to. An agent step's preview carries it, as the flow
* editor's own step test does: the run is filed under it, and a managed memory keyed on a
* memory id someone named reads the flow's conversation rather than a stray one. */
flowPath?: string
/** The flow editor's substitution of a linked agent's unsaved draft, passed in because it
* reaches the draft store, which this module's import list must not. */
withAgentDrafts?: (value: FlowValue) => Promise<FlowValue>
}
export type FlowStepTestRunConfig = FlowStepRunConfig & {
@@ -2121,8 +2267,9 @@ export type ResolvedFlowStepRun = {
startMessage: string
/** Takes the arguments as submitted. The preprocessor's entrypoint override is added
* here rather than by the caller: it is declared by no schema, so anything that
* conforms arguments to one would drop it. */
startJob: (args: Record<string, any>) => Promise<string>
* conforms arguments to one would drop it. An agent step also takes the run form's rows:
* each runs from its submitted value, including one the submission leaves out. */
startJob: (args: Record<string, any>, formKeys?: readonly string[]) => Promise<string>
}
function normalizeFlowStepArgs(args: Record<string, any> | null | undefined): Record<string, any> {
@@ -2166,7 +2313,9 @@ export async function resolveFlowStepRun({
toolCallbacks,
toolId,
loadScript = loadDeployedScriptForFlowStep,
loadSubflow
loadSubflow,
flowPath,
withAgentDrafts
}: FlowStepRunConfig): Promise<ResolvedFlowStepRun> {
const targetModule = findModuleInFlow(flowValue, stepId) ?? undefined
@@ -2253,12 +2402,51 @@ export async function resolveFlowStepRun({
}
}
if (moduleValue.type === 'aiagent') {
return {
module: targetModule,
// The job started is a flow preview: an agent step has none of its own, and the tool
// calls it makes are that flow's steps.
runnableKind: 'flow',
startMessage: `Starting test run of agent step "${stepId}"...`,
startJob: async (args, formKeys) => {
// The step alone, stripped of the settings around it: a mock would answer for it
// and a suspend would hold the run for an approval nobody is watching for.
const value: FlowValue = {
modules: [
{
id: targetModule.id,
value: {
...moduleValue,
input_transforms: agentTestInputTransforms(
moduleValue.input_transforms,
args,
formKeys
)
}
}
]
}
return JobService.runFlowPreview({
workspace,
requestBody: {
// After the inputs are wired, as the flow editor's step test does: a linked agent's
// draft brings its own brain and tools, and the step's run inputs stay on top.
value: withAgentDrafts ? await withAgentDrafts(value) : value,
args,
path: flowPath
}
})
}
}
}
toolCallbacks.setToolStatus(toolId, {
content: `Step type "${moduleValue.type}" not supported for testing`,
error: `Cannot test step of type "${moduleValue.type}"`
})
throw new Error(
`Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow`
`Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow, aiagent`
)
}
@@ -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>): 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(
'<span class="hljs-keyword">const</span> s = <span class="hljs-string">`a</span>'
)
expect(lines[1]).toMatch(/^<span class="hljs-string">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 &lt; 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()
}
})
})
@@ -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<string, (params: unknown) => 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<string, unknown>)[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<number, CharacterRange[]>()
const addedRanges = new Map<number, CharacterRange[]>()
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<string, number>
): 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<number, CharacterRange[]>
): 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<number, CharacterRange[]>,
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 } : {}) }
])
}
}
}
@@ -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 + '</span>'.repeat(openTags.length))
line = openTags.join('')
} else {
line += token
if (token.startsWith('<span')) openTags.push(token)
else if (token === '</span>') openTags.pop()
}
}
if (!hasFinalNewline) result.push(line)
return result
}
@@ -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<string, LanguageType<string>> = {
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<string> {
return LANGUAGE_BY_MONACO[language] ?? plaintext
}
+2 -1
View File
@@ -1433,7 +1433,8 @@ export async function parseOpenAICompletion(
toolCall,
helpers,
toolCallbacks: callbacks,
workspace: options?.workspace
workspace: options?.workspace,
messages
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
@@ -2,6 +2,7 @@
import { type Job } from '$lib/gen'
import ProgressBar from '../progressBar/ProgressBar.svelte'
import { forLater } from '$lib/forLater'
import type { Snippet } from 'svelte'
interface Props {
job?: Job | undefined
@@ -11,6 +12,7 @@
textPosition?: 'top' | 'bottom'
// Prefer step ID over step index when both are available
showStepId?: boolean
errorAction?: Snippet
}
let {
@@ -19,7 +21,8 @@
class: className,
slim = false,
textPosition = 'top',
showStepId = false
showStepId = false,
errorAction
}: Props = $props()
let error: number | undefined = $state(undefined)
@@ -183,4 +186,5 @@
{isCanceled}
{isScheduled}
{isSkipped}
{errorAction}
/>
@@ -6,6 +6,7 @@ import {
agentMemoryMode,
historyInputApplies,
agentFieldIsSet,
agentTestInputTransforms,
initialVisibleAgentFields
} from './agentFormFields'
@@ -147,3 +148,23 @@ describe('memoryPropertyFor', () => {
).toBeUndefined()
})
})
describe('agentTestInputTransforms', () => {
it('unsets a blank history input and feeds every other form key from flow_input', () => {
const authored = {
memory_id: { type: 'static' as const, value: 'thread-1' },
previous_messages: { type: 'static' as const, value: [{ role: 'user', content: 'hi' }] },
system_prompt: { type: 'javascript' as const, expr: 'results.a.prompt' }
}
expect(
agentTestInputTransforms(authored, { memory_id: '', previous_messages: [{ role: 'user' }] }, [
'memory_id',
'previous_messages',
'system_prompt'
])
).toEqual({
previous_messages: { type: 'javascript', expr: 'flow_input.previous_messages' },
system_prompt: { type: 'javascript', expr: 'flow_input.system_prompt' }
})
})
})
@@ -30,6 +30,28 @@ export const AGENT_TOOLS_ROW = 'tools'
export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const
export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number]
/** The input transforms a one-off test of an agent step runs with: each form key is fed from
* `flow_input`, over the step's own transforms. A blank history input is unset, as on the step:
* an expression evaluating to nothing reads as an empty memory id, and the step's transform is
* stale. */
export function agentTestInputTransforms(
stepTransforms: Record<string, InputTransform> | undefined,
args: Record<string, unknown>,
formKeys: readonly string[] = Object.keys(args)
): Record<string, InputTransform> {
const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length)
const kept = (key: string) =>
!(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
return {
...Object.fromEntries(Object.entries(stepTransforms ?? {}).filter(([key]) => kept(key))),
...Object.fromEntries(
formKeys
.filter(kept)
.map((key) => [key, { type: 'javascript' as const, expr: `flow_input.${key}` }])
)
}
}
/** What turning managed memory on writes. */
export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'window', context_length: 10 }
@@ -126,6 +126,11 @@ export function toolEnabledName(tool: AgentTool): string | undefined {
return toolDisplayName(tool)
}
/** The names `enabled_tools` can pick from: every tool in the roster that has one. */
export function enabledToolNames(tools: AgentTool[]): string[] {
return tools.map((tool) => toolEnabledName(tool)).filter((name): name is string => !!name)
}
/**
* Create an AI Agent tool (nested agent)
*/
@@ -1,7 +1,16 @@
<script lang="ts">
import { getContext, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import { Alert } from '$lib/components/common'
import { Info, MessageCircleOff } from 'lucide-svelte'
import { Alert, Button } from '$lib/components/common'
import FlowChat from '../conversations/FlowChat.svelte'
import {
AGENT_CHAT_SCHEMA,
agentChatFlow,
agentChatGap,
agentChatPath
} from '../conversations/agentEditorChat'
import { runFlowPreview } from '../utils.svelte'
import { deepEqual } from 'fast-equals'
import type { Flow, FlowModule, InputTransform, Job, OpenFlow } from '$lib/gen'
import { emptySchema, type StateStore } from '$lib/utils'
@@ -30,7 +39,12 @@
type AIAgentConfig
} from '../agentResourceUtils'
import { agentArgsToTransforms } from '../linkedAgentDrafts'
import { AGENT_EDITOR_RUN_INPUTS, AGENT_TOOLS_ROW } from '../agentFormFields'
import {
AGENT_EDITOR_RUN_INPUTS,
AGENT_TOOLS_ROW,
DEFAULT_AGENT_MEMORY,
keepsManagedMemory
} from '../agentFormFields'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import { useAgentDraft } from '../agentDraft.svelte'
import Path from '$lib/components/Path.svelte'
@@ -199,6 +213,17 @@
// apart from the echo of their own write.
let lastArgs = $state<string | undefined>(undefined)
// Picked on the first load, after which it is the reader's: following the memory setting live
// would pull them out of a chat for switching memory off in the form.
let testMode = $state<'form' | 'chat' | undefined>(undefined)
// Mounted the first time it is shown, then only hidden: unmounting destroys the SDK chat,
// which stops following the turn in flight, and its answer would never show.
let chatMounted = $state(false)
function showTestMode(mode: 'form' | 'chat') {
testMode = mode
if (mode === 'chat') chatMounted = true
}
// draft.args -> module. Only when the draft moved on its own (a load, an external write, a
// discard); an edit made in the form arrives here as its own echo and is skipped.
$effect(() => {
@@ -217,6 +242,7 @@
} as any
}
flowStore.val.value.modules = [built]
if (testMode === undefined) showTestMode(keepsManagedMemory(args.memory) ? 'chat' : 'form')
// Caught rather than left to float: a tool whose shape the schema loader cannot read
// would otherwise reject into the global unhandled-rejection handler, which reports the
// bare message and no stack — saying nothing about which agent or tool caused it. The
@@ -294,6 +320,43 @@
let testIsLoading = $state(false)
let scriptProgress = $state(undefined)
let chatPath = $derived(agentChatPath(path))
/** One chat turn: the agent as edited, not as deployed, the same as a run from the form. */
async function runChatTurn(
userMessage: string,
conversationId: string,
inputs?: Record<string, any>
): Promise<string | undefined> {
if (!agentModule) return undefined
return await runFlowPreview(
{ ...(inputs ?? {}), user_message: userMessage },
agentChatFlow($state.snapshot(agentModule) as FlowModule),
chatPath,
undefined,
conversationId,
undefined,
workspace
)
}
// What the composer reads for its model button and paperclip.
let chatModules = $derived(agentModule ? agentChatFlow(agentModule).value.modules : undefined)
let chatGap = $derived(agentChatGap(agentValue?.input_transforms))
function turnOnMemory() {
if (!agentValue) return
agentValue.input_transforms.memory = {
type: 'static',
value: structuredClone(DEFAULT_AGENT_MEMORY)
}
}
function turnOnStreaming() {
if (!agentValue) return
agentValue.input_transforms.streaming = { type: 'static', value: true }
}
// Adding a tool goes straight into it: the editor has no graph to show the new node on, so the
// tool it just created is the only place the click can land.
async function addTool(detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) {
@@ -341,6 +404,18 @@
export function draftHandle() {
return draft
}
/** The switch between the form and the chat, which the dialog's header holds. `mode` is
* undefined until the agent loads and picks the first one. */
export function testPaneHandle() {
return {
get mode() {
return testMode
},
set mode(mode: 'form' | 'chat' | undefined) {
if (mode) showTestMode(mode)
}
}
}
</script>
{#if draft.refusal}
@@ -359,7 +434,7 @@
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
answer want opposite splits, and only the reader knows which they are on. -->
<Splitpanes class="h-full">
<Pane size={66} minSize={30}>
<Pane size={55} minSize={30}>
<div class="h-full min-h-0 overflow-auto">
<div class="px-4 pt-4">
<Label label="Path">
@@ -410,37 +485,98 @@
</PropPickerWrapper>
</div>
</Pane>
<!-- Laid out as the script editor's preview column is: what a run takes above what it
produced, both alongside what is being edited. -->
<Pane size={34} minSize={20}>
<Splitpanes horizontal class="h-full">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
/>
<Pane size={45} minSize={20}>
<div class="h-full min-h-0 flex flex-col">
<!-- Laid out as the script editor's preview column is: what a run takes above what it
produced, both alongside what is being edited. -->
<div class="flex-1 min-h-0 {testMode === 'chat' ? 'hidden' : ''}">
<Splitpanes horizontal class="h-full">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
/>
</div>
</Pane>
<Pane size={60} minSize={20}>
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</Pane>
</Splitpanes>
</div>
{#if chatMounted}
<div class={testMode === 'chat' ? 'flex flex-col flex-1 min-h-0' : 'hidden'}>
{#if chatGap?.memory}
<div
class="flex-1 flex flex-col items-center justify-center gap-2 px-8 text-center"
>
<MessageCircleOff size={48} class="text-tertiary opacity-50 mb-2" />
<p class="text-sm font-semibold text-emphasis">Chat needs managed memory</p>
<p class="text-xs text-secondary max-w-xs">
{chatGap.memoryCanTurnOn
? 'Without it, every message would be answered without the ones before it.'
: 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'}
</p>
{#if chatGap.memoryCanTurnOn && !readOnly}
<Button
unifiedSize="sm"
variant="default"
btnClasses="bg-surface mt-2"
onClick={turnOnMemory}
>
Turn on managed memory
</Button>
{/if}
</div>
{:else if chatGap?.noStream}
<div
class="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b text-2xs text-secondary"
>
<Info size={12} class="shrink-0" />
<span class="flex-1">
{chatGap.noStream === 'image'
? 'Image answers do not stream: each one shows once its run ends.'
: 'Streaming is off: each answer shows once its run ends.'}
</span>
{#if chatGap.noStream === 'off' && !readOnly}
<Button unifiedSize="2xs" variant="subtle" onClick={turnOnStreaming}>
Turn on
</Button>
{/if}
</div>
{/if}
<!-- Hidden rather than unmounted while memory is off: switching it off mid-turn must
not end the chat following that turn. Test chats, since what runs is the agent as
edited. -->
<div class={chatGap?.memory ? 'hidden' : 'flex flex-col flex-1 min-h-0'}>
<FlowChat
onRunFlow={runChatTurn}
path={chatPath}
conversationKind="test"
subject="agent"
frame="none"
inputSchema={AGENT_CHAT_SCHEMA}
flowModules={chatModules}
/>
</div>
</div>
</Pane>
<Pane size={60} minSize={20}>
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</Pane>
</Splitpanes>
{/if}
</div>
</Pane>
</Splitpanes>
</div>
@@ -1,5 +1,7 @@
<script lang="ts">
import { FlaskConical, History, Save } from 'lucide-svelte'
import { FlaskConical, FormInput, History, MessageSquare, Save } from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { onDestroy, untrack } from 'svelte'
import { resource } from 'runed'
import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte'
@@ -93,6 +95,7 @@
})
let draft = $derived(host?.draftHandle())
let testPane = $derived(host?.testPaneHandle())
// The load refused this path, so there is no agent to deploy, evaluate or show a history of:
// the dialog carries only the refusal the host renders.
let refused = $derived(draft?.refusal != null)
@@ -268,6 +271,38 @@
{#snippet settings()}
<div class="flex flex-row items-center gap-2 shrink-0">
{#if !inEvals && !refused}
<!-- Switches the editor's right-hand pane, and is drawn only once the agent has
loaded and the pane has picked its first mode. -->
{#if testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="sm"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="sm"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{#if readOnly}
<Badge
color="gray"

Some files were not shown because too many files have changed in this diff Show More