mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
Generated
+5
@@ -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",
|
||||
|
||||
@@ -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 @@
|
||||
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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface SyncOptions {
|
||||
|
||||
export interface Codebase {
|
||||
relative_path: string;
|
||||
extra_digest_paths?: string[];
|
||||
includes?: string[];
|
||||
excludes?: string[];
|
||||
assets?: {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) || ' '}</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 < 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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user