Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
da4ccdb
fix(WS-SETTINGS-A): Settings > App > Version shows Biorouter's versio…
Broccolito Oct 12, 2026
4d4149f
fix(WS-PICKERS): final check, the Tools popover's switches are named …
Broccolito Oct 12, 2026
8b190ec
fix(apps): an app arms an extension the person installed from that ma…
Broccolito Oct 12, 2026
4d9652b
test(apps): pin the fallback bundler on the input reported in #386
Broccolito Oct 12, 2026
a671173
fix(WS-MOTION): final check, a toast in the corner no longer covers t…
Broccolito Oct 12, 2026
c3bbebb
fix(WS-MOTION): the Extensions page's failure notice takes the place …
Broccolito Oct 12, 2026
fcc83fd
feat(crew): Crew is a Biorouter capability, switched in Settings, not…
Broccolito Oct 12, 2026
5c9522c
fix(knowledge): Daily Meditation upgrades every Meditation text Bioro…
Broccolito Oct 12, 2026
ee2f0c5
fix(skills): a workflow naming soul-writer loads update-soul, and a m…
Broccolito Oct 12, 2026
4e2529a
fix(scheduler): a run that fails before its first message leaves no e…
Broccolito Oct 12, 2026
1399987
docs(extensions): the load toast's comments date the twelve-entry cap…
Broccolito Oct 12, 2026
285ea0f
fix(scheduler): a run whose turn was aborted is a failure, and Daily …
Broccolito Oct 12, 2026
7555320
fix(providers): a reply of tool calls written as text is asked about …
Broccolito Oct 12, 2026
06efc28
fix(ollama): a first prompt that outlasts OLLAMA_TIMEOUT says so and …
Broccolito Oct 12, 2026
6dc0747
test(ollama): replay diagnostics 20261006_2 through the provider and …
Broccolito Oct 12, 2026
433897f
docs(ollama): a reply of calls nothing ran is said, two calls are ask…
Broccolito Oct 12, 2026
f4fb03b
fix(settings): the top of Settings and the setup page is never left u…
Broccolito Oct 12, 2026
c241780
fix(agent): the live copy of a reply keeps who it is for, so a user-o…
Broccolito Oct 12, 2026
50a6a3c
test(crew): cover capability placement in the self-test workflow
Broccolito Oct 12, 2026
49443a7
fix(extensions): withdraw failure toasts from every chat
Broccolito Oct 12, 2026
786d027
fix(ollama): explain response body idle timeouts
Broccolito Oct 12, 2026
4f950d9
fix(ollama): retain timeout causes and notice malformed text calls
Broccolito Oct 12, 2026
d2edbb4
feat(apps): reuse verified service and check pages by default
Broccolito Oct 13, 2026
ec00365
test(apps): verify appended request logs without string slicing
Broccolito Oct 13, 2026
f364196
refactor(apps): split authored page inspection for lint compliance
Broccolito Oct 13, 2026
cbb6600
test(extensions): wait for the dialog overlay before clicking outside
Broccolito Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,10 @@ prompt both catalog models made structured tool calls reliably.
sticky per instance, text held until the size check passes.
- **Every request streams**, blocking ones collected, so `OLLAMA_TIMEOUT`
(600 s) bounds silence, never a whole generation; a timeout is never retried
(`TimeoutRetry::GiveUp`), a connection failure still is. An unpinned turn gets
(`TimeoutRetry::GiveUp`), a connection failure still is. One before the reply
begins is `ProviderError::IdleTimeout`, naming the seconds, the prompt's rough
size and the setting, and the agent does not re-send it either (it used to,
as a network blip: diagnostics 20261006_2). An unpinned turn gets
`num_predict` 16384, or 32768 when the model may think.
- **Thinking:** `think` only to models whose `/api/show` capabilities include
`thinking` (others answer `think:true` with a 400). Quick sends `false`;
Expand Down Expand Up @@ -856,14 +859,22 @@ prompt both catalog models made structured tool calls reliably.
beyond a ceiling for the reasoning (characters/2 + 16 tokens) plus 8, else
"returned reasoning only (N output tokens)"; a reply cut at `length`, or
one with no reasoning and at most 8 tokens, fails at once; (3) a WHOLE reply
that is a call recovery declined (`text_tool_calls::attempted_call`: a name
that is a call recovery declined (`text_tool_calls::attempted_calls`: a name
without its `extension__` prefix, arguments that do not fit, the
`<tool_response>` tag), whose name stands for an offered tool. That one
`<tool_response>` tag; several objects or an array, each one naming an
offered tool), whose name stands for an offered tool. That one
asks rather than orders, is skipped when the call echoes the conversation
(made by an earlier turn, or quoted in a tool result or earlier assistant
reply; `echoes_conversation`), and is best-effort: if it cannot be sent,
fails or comes back empty, the turn ends with the reply already shown. A
retry after shown text starts a new paragraph.
- **A reply of calls nothing ran is SAID, never run** (diagnostics
20261006_2): when a turn that offered tools ends on a reply made only of
call objects written as text (`whole_reply_call_names`), `agents::reply_parts`
adds one user-only inline notice after it ("Nothing ran: ..."), for every
provider. It is stored, so a reload shows it, and every wire format drops it
because it is not agent-visible. Calls Code Execution hides (`shell`,
`text_editor`) still get no retry: the provider cannot see them.
- **Tool roster for templates that hide tools** (R8): when `/api/show`'s
template shows tools only next to the last user message (llama3.1,
mistral-small3.2, mistral-nemo; `ModelFacts::tools_only_near_last_user`),
Expand Down
4 changes: 3 additions & 1 deletion biorouter-self-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ instructions: |
- Enable/disable extensions
- Test extension interactions
- Verify isolation between extensions
- In the desktop, verify Crew appears in Settings > Chat > Capabilities and is absent from the Extensions page and the composer's Tools popover. Preserve the fixture's existing Crew on/off choice.
- In a disposable chat, browse `/` and `/ext:` and confirm current capability labels including Biorouter Copilot. Select a session-only extension and verify its exact reference identity.
- Submit `/extend Biorouter Copilot` and verify guidance names `/ext:BiorouterCopilot` without model execution or a permission grant.
- Verify `/effort` and a harmless configured workflow in the desktop, TUI, and classic CLI. A missing workflow must report its problem instead of falling through to the model.
Expand Down Expand Up @@ -210,6 +211,7 @@ instructions: |
fixture or desktop leaves GUI evidence unavailable; never claim a skipped action passed.

For a Crew-enabled fixture, additionally:
- Toggle Crew in Settings > Chat > Capabilities, reload the disposable fixture, and verify the choice persists; restore its original value.
- Confirm the empty `/crew` view identifies the isolated profile and does not imply authentication success when no workspace exists
- Exercise the workspace form's required connection, invitation, and pinned-key validation using synthetic values only
- Verify first-host preparation is explicit, host-key trust is visible, and no private key or provider credential is entered into the fixture
Expand Down Expand Up @@ -687,7 +689,7 @@ prompt: |
1. Call `agent_drafter__list_apps`. If that exact app id already exists or matches the preview id `{{ preview_app_id }}`, stop this phase and report the collision without modifying any app. Do not overwrite, reuse, or remove a prior run's app.
2. Call `create_app` with that id, kind `agentic`, archetype `dashboard`, a concise title and description, and a system prompt that updates dashboard metrics through the declared action.
3. Call `read_app` and verify the resolved manifest has the dashboard state schema, `set_metric` action, `refresh_requested` signal, and nonblank `surface.state_initial` metric labels. Missing measurements must be explicitly unavailable, not invented zero values. Keep declared initial state as the source of first-load values; do not add duplicate imperative initialization to work around the smoke harness.
4. Call `build_app`; require a successful bundle.
4. Call `build_app`; require a successful bundle and a passing default browser-free page check. This checks page files and JavaScript structure; it does not establish browser execution. A quality-check error must fail the build rather than produce a success card.
5. Call `build_app` with `check_only: true`; require zero ERROR findings.
6. Call `smoke_app`; require the real-browser harness to execute successfully. A missing browser or skipped execution is a failure, not a pass.
7. Record setup, manifest assertions, build output, lint output, and smoke output in {{ workspace_dir }}/phase2b_apps.md.
Expand Down
34 changes: 9 additions & 25 deletions crates/biorouter-cli/src/session/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1590,33 +1590,17 @@ fn count_skills() -> usize {
count_user_skills_in(&list_skills())
}

/// The six foundational built-ins surfaced as their own "Capabilities" section
/// in the GUI and intentionally excluded from the extension list/count there
/// (see ui/desktop/.../capabilities/capabilities.ts). The CLI status line was
/// counting them as extensions, so it showed e.g. 11 where the GUI shows 5.
/// Keys are normalized like the GUI's `nameToKey` (whitespace removed, lowercased).
const CAPABILITY_KEYS: [&str; 6] = [
"developer",
"extensionmanager",
"skills",
"todo",
"memory",
"knowledge",
];

fn name_to_key(name: &str) -> String {
name.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_lowercase()
}

/// Enabled extensions, excluding the foundational capabilities — matches the
/// count the GUI shows.
/// Enabled extensions, excluding Biorouter's own capabilities, which the GUI
/// lists in Settings → Chat → Capabilities and leaves out of every extension
/// count (`ui/desktop/.../capabilities/capabilities.ts`). It asks
/// `ExtensionConfig::is_capability`, the daemon's own classification, rather
/// than a list of keys here: the list this replaced named six of the fourteen
/// capabilities, so the status line counted Biorouter Copilot, Crew, Workspace
/// Control and five more as extensions the person had installed.
fn count_non_capability_extensions() -> usize {
biorouter::config::get_enabled_extensions()
.iter()
.filter(|ext| !CAPABILITY_KEYS.contains(&name_to_key(&ext.name()).as_str()))
.filter(|ext| !ext.is_capability())
.count()
}

Expand Down Expand Up @@ -2411,7 +2395,7 @@ mod tests {
/// (#77). The five Contexts are seeded into the same directory on every
/// start, so before this exclusion the terminal reported "5 skills" on a
/// machine with none and disagreed with the GUI chip about the same word —
/// the drift `CAPABILITY_KEYS` already fixes for extensions.
/// the drift `count_non_capability_extensions` already fixes for extensions.
///
/// Nothing asserted it. `count_skills` reads the real config directory, and
/// on the common machine (contexts seeded, no user skills) the filtered and
Expand Down
191 changes: 189 additions & 2 deletions crates/biorouter-mcp/src/agent_drafter/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ pub fn lint_app(project_dir: &Path) -> Vec<LintFinding> {
let manifest: Option<Manifest> = std::fs::read_to_string(project_dir.join("manifest.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok());
out.extend(super::page_check::findings(
project_dir,
manifest.as_ref().map_or("index.html", |m| m.entry.as_str()),
true,
));
let is_agentic = manifest
.as_ref()
.is_none_or(|manifest| manifest.kind == ArtifactKind::Agentic);
Expand Down Expand Up @@ -934,7 +939,7 @@ pub fn format_lint(findings: &[LintFinding]) -> String {
s.push_str(&format!(" {tag}: {}\n", f.msg));
}
if findings.iter().any(|f| f.level == LintLevel::Error) {
s.push_str("Fix the ERRORs (they break backend access or portability), then rebuild.");
s.push_str("Fix the ERRORs, then rebuild.");
}
s
}
Expand All @@ -958,7 +963,7 @@ pub struct BuildReport {
}

impl BuildReport {
fn not_built(used: &str, log: impl Into<String>) -> Self {
pub(super) fn not_built(used: &str, log: impl Into<String>) -> Self {
Self {
ok: false,
used: used.to_string(),
Expand Down Expand Up @@ -1741,6 +1746,15 @@ impl BundleMaking<'_> {
let staging = Staging::new(&dist)?;
match run_esbuild(&cmd, &entry, &staging.0.join("app.js")) {
Ok(mut report) if report.ok => {
let js = std::fs::read_to_string(staging.0.join("app.js"))?;
if let Some(finding) =
super::page_check::javascript_error(&js, "dist/app.js", false)
{
return Ok(BuildReport::not_built("esbuild", finding.msg));
}
if let Some(error) = staged_page_error(self.project_dir, &staging.0, true) {
return Ok(BuildReport::not_built("esbuild", error));
}
let banner = BundleBanner {
builder: "esbuild".into(),
sdk: self.sdk.clone(),
Expand Down Expand Up @@ -1782,6 +1796,23 @@ impl BundleMaking<'_> {
}
}

fn staged_page_error(project_dir: &Path, staging: &Path, replaces_css: bool) -> Option<String> {
let manifest: Option<Manifest> = std::fs::read_to_string(project_dir.join("manifest.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok());
if manifest.is_none() && !project_dir.join("index.html").is_file() {
return None;
}
let entry = manifest.as_ref().map_or("index.html", |m| m.entry.as_str());
let findings = super::page_check::staged_findings(project_dir, entry, staging, replaces_css);
(!findings.is_empty()).then(|| {
format!(
"Browser-free page check failed:\n{}",
format_lint(&findings)
)
})
}

/// A fresh hidden directory in `dist/` for esbuild to write into, removed when
/// dropped.
///
Expand Down Expand Up @@ -2186,6 +2217,16 @@ fn fallback_bundle(
strip_module_syntax(&sdk_source),
strip_module_syntax(main)
);
if let Some(finding) =
super::page_check::javascript_error(&js, "dist/app.js fallback output", false)
{
return Ok(BuildReport::not_built("fallback", format!("The fallback bundler's output does not parse, so it was not installed:\n{}\n{FALLBACK_NEEDS_ESBUILD}", finding.msg)));
}
let staging = Staging::new(out.parent().unwrap_or(project_dir))?;
write_atomic(&staging.0.join("app.js"), js.as_bytes())?;
if let Some(error) = staged_page_error(project_dir, &staging.0, false) {
return Ok(BuildReport::not_built("fallback", error));
}
let verified = match node_check(&js, out.parent().unwrap_or(project_dir)) {
Ok(None) => "It passed `node --check`.",
Ok(Some(error)) => {
Expand Down Expand Up @@ -3035,6 +3076,104 @@ mod tests {
assert_eq!(names, vec!["app.js".to_string()]);
}

#[test]
#[cfg(unix)]
fn a_page_check_failure_keeps_the_previous_javascript_and_stylesheet() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(
dir.path().join("src/main.ts"),
"document.body.dataset.ready = 'new';",
)
.unwrap();
std::fs::write(
dir.path().join("index.html"),
r#"<link rel="stylesheet" href="dist/app.css"><p>App</p>"#,
)
.unwrap();
let dist = dir.path().join("dist");
std::fs::create_dir(&dist).unwrap();
std::fs::write(dist.join("app.js"), "old();\n").unwrap();
std::fs::write(dist.join("app.css"), "body{color:red}\n").unwrap();
let make = || BundleMaking {
project_dir: dir.path(),
main: "document.body.dataset.ready = 'new';",
includes_sdk: false,
sdk: "none".into(),
sdk_version: None,
};
let compiler = || {
EsbuildCommand {
program: "/bin/sh".into(),
lead: vec!["-c".into(), r#"for arg in "$@"; do case "$arg" in --outfile=*) printf 'updated();\n' > "${arg#--outfile=}";; esac; done"#.into(), "fixture-compiler".into()],
child_path: None,
source: "fixture",
}
};
let failed = make().build(Some(compiler())).unwrap();
assert!(
!failed.ok && failed.log.contains("dist/app.css"),
"{}",
failed.log
);
assert_eq!(
std::fs::read_to_string(dist.join("app.js")).unwrap(),
"old();\n"
);
assert_eq!(
std::fs::read_to_string(dist.join("app.css")).unwrap(),
"body{color:red}\n"
);
std::fs::write(dir.path().join("index.html"), "<p>No stylesheet needed</p>").unwrap();
assert!(make().build(Some(compiler())).unwrap().ok);
assert!(!dist.join("app.css").exists());
}

#[test]
fn a_failed_fallback_page_check_does_not_replace_its_previous_bundle() {
let dir = project(None, "document.body.dataset.ready = 'old';\n");
let first = fallback_of(&dir);
assert!(first.ok, "{}", first.log);
let old = std::fs::read_to_string(dir.path().join("dist/app.js")).unwrap();
std::fs::write(dir.path().join("dist/app.css"), "body{color:red}\n").unwrap();
std::fs::write(
dir.path().join("index.html"),
r#"<link rel="stylesheet" href="dist/app.css"><script src="assets/missing.js"></script><p>App</p>"#,
)
.unwrap();
std::fs::write(
dir.path().join("src/main.ts"),
"document.body.dataset.ready = 'new';\n",
)
.unwrap();
let report = fallback_of(&dir);
assert!(
!report.ok && report.log.contains("assets/missing.js"),
"{}",
report.log
);
assert_eq!(
std::fs::read_to_string(dir.path().join("dist/app.js")).unwrap(),
old
);
assert_eq!(
std::fs::read_to_string(dir.path().join("dist/app.css")).unwrap(),
"body{color:red}\n"
);
std::fs::create_dir(dir.path().join("assets")).unwrap();
std::fs::write(dir.path().join("assets/missing.js"), "const value = 1;").unwrap();
let fixed = fallback_of(&dir);
assert!(fixed.ok, "{}", fixed.log);
assert_ne!(
std::fs::read_to_string(dir.path().join("dist/app.js")).unwrap(),
old
);
assert_eq!(
std::fs::read_to_string(dir.path().join("dist/app.css")).unwrap(),
"body{color:red}\n"
);
}

/// ⚠ esbuild used to write straight to a hidden temp outfile, and it puts
/// an imported stylesheet beside the outfile with the extension swapped, so
/// `dist/app.css` was never written and hidden `.css` files piled up. Needs
Expand Down Expand Up @@ -3347,6 +3486,54 @@ mod tests {
);
}

/// Issue #386, the input that was reported. Without esbuild the stripper
/// turned `{ omop_rules: OMOP_RULES, required_outputs: Object.keys(patient) }`
/// into `{ omop_rules , required_outputs , … }`, and dropped the local
/// modules `./prevent` and `./preventData` without a word. The literal on
/// one line now keeps its values and builds; the same literal across lines
/// and the local modules are refused, by line, with nothing written.
#[test]
fn the_reported_literal_keeps_its_values_and_local_modules_are_refused() {
let literal =
"const req = { omop_rules: OMOP_RULES, required_outputs: Object.keys(patient) };";
assert_eq!(strip_colon_types(literal), literal);
let call = " br.agent.send({ omop_rules: OMOP_RULES, required_outputs: Object.keys(p) });";
assert_eq!(strip_colon_types(call), call);

let sdk = "export function createApp(x: unknown) { globalThis.req = x; }\n";
let main = format!(
"import {{ createApp }} from \"./sdk\";\nconst OMOP_RULES = [\"r1\"];\n\
const patient = {{ age: 1 }};\n{literal}\ncreateApp(req);\n"
);
let dir = project(Some(sdk), &main);
let report = fallback_of(&dir);
assert!(report.ok, "{}", report.log);
let js = std::fs::read_to_string(dir.path().join("dist/app.js")).unwrap();
assert!(js.contains(literal), "{js}");
if let Some(parsed) = node_parses(&js) {
parsed.expect("the fallback bundle parses");
}

for (main, expect) in [
(
"import { createApp } from \"./sdk\";\nimport { risk } from \"./prevent\";\n\
import { OMOP_RULES } from \"./preventData\";\ncreateApp(risk(OMOP_RULES));\n",
"./prevent\"",
),
(
"import { createApp } from \"./sdk\";\nconst req = {\n omop_rules: OMOP_RULES,\n \
required_outputs: Object.keys(patient),\n};\ncreateApp(req);\n",
"line 3",
),
] {
let dir = project(Some(sdk), main);
let report = fallback_of(&dir);
assert!(!report.ok, "refused: {main}");
assert!(report.log.contains(expect), "{main}\n---\n{}", report.log);
assert!(!dir.path().join("dist/app.js").exists(), "{main}");
}
}

/// The banner is the bundle's own record of what built it, read back from
/// the file; a bundle from before banners is recognised as a fallback only
/// by the marker the stripper used to write.
Expand Down
Loading
Loading