Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 7 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,11 @@ concurrency:
# Gate policy (mirrors ssvlabs/brains: introduce currently-red lints in warn
# mode first, ratchet to blocking once the tree is clean):
# BLOCKING: eslint, i18n check, vitest, vite build, cargo fmt,
# cargo test (macOS only - see below).
# cargo test (all three OSes).
# WARN MODE (continue-on-error, red today on main - ratchet later):
# - prettier format:check (244 files unformatted on main)
# - svelte-check (1 error: `process` w/o @types/node + a11y warns)
# - clippy -D warnings (doc_lazy_continuation lints in build.rs)
# - cargo test on Windows/Linux (~14 tests carry Unix-path assumptions that
# had never run off-macOS; the suite now compiles everywhere via the
# recall-node externalBin stub in the Rust job)
jobs:
frontend:
name: Frontend
Expand Down Expand Up @@ -166,27 +163,15 @@ jobs:
exit 1
fi

# BLOCKING on macOS: the reference platform (macOS is the shipped build).
# BLOCKING on every OS. The Windows/Linux legs ran in warn mode while ~14
# tests carried Unix-path assumptions (hard-coded /home/... literals,
# separator-sensitive asserts); those are fixed, so a Windows-only compile
# error or test regression now actually blocks - which matters because
# Windows-only code (`#[cfg(target_os = "windows")]`) is compiled by no
# other leg.
- name: Unit tests
if: runner.os == 'macOS'
run: cargo test --manifest-path src-tauri/Cargo.toml

# WARN MODE on Windows/Linux: the suite now compiles and runs everywhere
# (recall-node stub above), but ~14 tests carry Unix-path assumptions
# (hard-coded /home/... literals, separator-sensitive asserts) that had
# never been exercised off-macOS. Kept visible, not skipped; ratchet to
# blocking once those tests are made path-agnostic.
- name: Unit tests (warn mode - non-mac unix-path assumptions)
if: runner.os != 'macOS'
continue-on-error: true
shell: bash
run: |
if ! cargo test --manifest-path src-tauri/Cargo.toml; then
echo ""
echo "::warning::cargo test failed on ${{ runner.os }} (non-blocking; pre-existing Unix-path test assumptions)."
exit 1
fi

build-windows:
name: Build Windows
if: contains(github.event.pull_request.labels.*.name, 'build-windows') || contains(github.event.pull_request.labels.*.name, 'build-test')
Expand Down
38 changes: 24 additions & 14 deletions src-tauri/src/commands/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,16 +483,26 @@ mod tests {
}
}

// file_uri_to_path delegates to url::Url::to_file_path, whose accepted shapes
// are platform-specific by design: a driveless file:///home/... URI is an error
// on Windows, and file:///C:/... is an error on Unix. Same assertions on both
// platforms, so the fixtures are what varies - not the strength of the test.
#[cfg(not(windows))]
const HOME: (&str, &str) = ("file:///home/user", "/home/user");
#[cfg(windows)]
const HOME: (&str, &str) = ("file:///C:/Users/user", r"C:\Users\user");
const SEP: char = std::path::MAIN_SEPARATOR;

#[test]
fn parse_file_uri_basic() {
let result = file_uri_to_path("file:///home/user/doc.txt");
assert_eq!(result, Some("/home/user/doc.txt".to_string()));
let result = file_uri_to_path(&format!("{}/doc.txt", HOME.0));
assert_eq!(result, Some(format!("{}{}doc.txt", HOME.1, SEP)));
}

#[test]
fn parse_file_uri_with_spaces() {
let result = file_uri_to_path("file:///home/user/my%20file.txt");
assert_eq!(result, Some("/home/user/my file.txt".to_string()));
let result = file_uri_to_path(&format!("{}/my%20file.txt", HOME.0));
assert_eq!(result, Some(format!("{}{}my file.txt", HOME.1, SEP)));
}

#[test]
Expand All @@ -504,25 +514,25 @@ mod tests {

#[test]
fn parse_file_uri_ignores_comments() {
let input = "# comment line\nfile:///home/user/doc.txt\n# another comment\n";
let result = parse_uri_list(input);
assert_eq!(result, vec!["/home/user/doc.txt".to_string()]);
let input = format!("# comment line\n{}/doc.txt\n# another comment\n", HOME.0);
let result = parse_uri_list(&input);
assert_eq!(result, vec![format!("{}{}doc.txt", HOME.1, SEP)]);
}

#[test]
fn parse_uri_list_multiple() {
let input = "file:///home/user/a.txt\nfile:///home/user/b.pdf\n";
let result = parse_uri_list(input);
let input = format!("{}/a.txt\n{}/b.pdf\n", HOME.0, HOME.0);
let result = parse_uri_list(&input);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "/home/user/a.txt");
assert_eq!(result[1], "/home/user/b.pdf");
assert_eq!(result[0], format!("{}{}a.txt", HOME.1, SEP));
assert_eq!(result[1], format!("{}{}b.pdf", HOME.1, SEP));
}

#[test]
fn parse_uri_list_mixed() {
let input = "# comment\nfile:///home/user/doc.txt\nhttps://example.com\n\n";
let result = parse_uri_list(input);
assert_eq!(result, vec!["/home/user/doc.txt".to_string()]);
let input = format!("# comment\n{}/doc.txt\nhttps://example.com\n\n", HOME.0);
let result = parse_uri_list(&input);
assert_eq!(result, vec![format!("{}{}doc.txt", HOME.1, SEP)]);
}

#[test]
Expand Down
28 changes: 18 additions & 10 deletions src-tauri/src/commands/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,17 @@ pub(crate) fn scan_memory_md_files(
files
}

/// Render a relative path as a `/`-separated logical label, whatever the platform.
/// Labels are UI-facing names (the candidate's `path` field carries the real OS
/// path); on Windows `display()` would yield `plans\feat.md`, which reads as an
/// escape sequence and differs from how MEMORY.md indexes link these files.
fn slash_label(rel: &std::path::Path) -> String {
rel.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}

fn scan_md_inner(
dir: &std::path::Path,
base: &std::path::Path,
Expand Down Expand Up @@ -452,15 +463,12 @@ fn scan_md_inner(
if p.is_dir() {
scan_md_inner(&p, base, files, depth + 1, max_depth, max_files);
} else if p.extension().and_then(|e| e.to_str()) == Some("md") {
let label = p
.strip_prefix(base)
.map(|r| r.display().to_string())
.unwrap_or_else(|_| {
p.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
let label = p.strip_prefix(base).map(slash_label).unwrap_or_else(|_| {
p.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
files.push(crate::models::MemoryFileCandidate {
path: p.display().to_string(),
label,
Expand Down Expand Up @@ -566,7 +574,7 @@ pub fn list_memory_files(
// Root-level files show as just "AGENTS.md"
let label = p
.strip_prefix(label_base)
.map(|r| r.display().to_string())
.map(slash_label)
.unwrap_or_else(|_| name.to_string());
files.push(crate::models::MemoryFileCandidate {
path: p.display().to_string(),
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/src/commands/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,9 +495,14 @@ mod tests {
"win path",
)];

// Normalization: backslashes -> forward slashes
// Normalization: backslashes -> forward slashes. On Windows the result is
// additionally lowercased - NTFS paths are case-insensitive, so `C:\Users`
// and `c:\users` name the same project and must filter as one.
let norm = normalize_path(r"C:\Users\dev\repo\a");
#[cfg(not(windows))]
assert_eq!(norm, "C:/Users/dev/repo/a");
#[cfg(windows)]
assert_eq!(norm, "c:/users/dev/repo/a");

// Filter with forward slashes should match
let filters = RunSearchFilters {
Expand Down
27 changes: 13 additions & 14 deletions src-tauri/src/commands/recall_recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,24 +911,13 @@ pub fn prepare(app: &tauri::AppHandle) -> Result<(), String> {
Ok(())
}

/// Windows: re-arm Recall when the user changes the system default microphone.
///
/// The sidecar binds an input device when `prepare` warms the audio pipeline and keeps it for the
/// life of the process, so changing the default device in Windows Sound settings was silently
/// ignored: recordings kept capturing the old (possibly signal-less) device. Worse, the
/// single-instance guard makes "close and reopen the app" a no-op - the second launch just
/// focuses the existing window - so users had no realistic way to pick up the new microphone
/// short of killing the process. macOS observes device moves via CoreAudio; Windows has no
/// equivalent wired up here, so poll the default capture endpoint and respawn the idle sidecar
/// when it changes (a fresh spawn is the one path verified to bind the new device).
/// The two decisions the Windows default-microphone watcher makes, deliberately kept free of
/// COM and of `#[cfg(target_os = "windows")]`.
///
/// Both behaviours here were asked for in review, and both are the kind a later edit undoes
/// silently. A test next to the watcher could not hold them: the watcher is Windows-only, so
/// its only compile gate is the Windows CI leg, which is `continue-on-error` and already red
/// from pre-existing failures - a test there cannot go red in a way anyone sees. Hoisting the
/// policy out of the cfg puts it under the macOS and Linux legs, which are blocking today.
/// silently. Living outside the cfg, they are compiled and tested on every CI leg rather than
/// only the Windows one - so a regression goes red everywhere, not just on the one platform
/// runner that happens to build the watcher.
/// `watch()` itself stays untestable (infinite loop, real sleeps, live COM); this is the part
/// worth pinning.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))] // only the tests call it elsewhere
Expand Down Expand Up @@ -1014,6 +1003,16 @@ mod mic_watch_policy {
}
}

/// Windows: re-arm Recall when the user changes the system default microphone.
///
/// The sidecar binds an input device when `prepare` warms the audio pipeline and keeps it for the
/// life of the process, so changing the default device in Windows Sound settings was silently
/// ignored: recordings kept capturing the old (possibly signal-less) device. Worse, the
/// single-instance guard makes "close and reopen the app" a no-op - the second launch just
/// focuses the existing window - so users had no realistic way to pick up the new microphone
/// short of killing the process. macOS observes device moves via CoreAudio; Windows has no
/// equivalent wired up here, so poll the default capture endpoint and respawn the idle sidecar
/// when it changes (a fresh spawn is the one path verified to bind the new device).
#[cfg(target_os = "windows")]
mod default_mic_watch {
use super::mic_watch_policy::{device_to_remember, should_respawn};
Expand Down
24 changes: 16 additions & 8 deletions src-tauri/src/storage/community_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -784,10 +784,14 @@ mod tests {
.join("skills")
.join(&slug)
.join("SKILL.md");
assert_eq!(
path.to_str().unwrap(),
"/home/user/.claude/skills/vercel-react-best-practices/SKILL.md"
);
// Compare PathBufs, not rendered strings - the separator is the platform's
// business; what this test pins is the slug and the directory layout.
let expected: std::path::PathBuf = ["/home/user", ".claude", "skills"]
.iter()
.collect::<std::path::PathBuf>()
.join("vercel-react-best-practices")
.join("SKILL.md");
assert_eq!(path, expected);
}

#[test]
Expand All @@ -798,10 +802,14 @@ mod tests {
.join("skills")
.join(&slug)
.join("SKILL.md");
assert_eq!(
path.to_str().unwrap(),
"/project/.claude/skills/react-components/SKILL.md"
);
// As above: PathBuf equality, so the `:` -> `-` slug rewrite is what's
// asserted rather than the platform's separator.
let expected: std::path::PathBuf = ["/project", ".claude", "skills"]
.iter()
.collect::<std::path::PathBuf>()
.join("react-components")
.join("SKILL.md");
assert_eq!(path, expected);
}

#[test]
Expand Down
35 changes: 26 additions & 9 deletions src-tauri/src/storage/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2119,12 +2119,19 @@ mod codex_skill_tests {
let user_dir = tmp.path().join("user_skills");
write_skill(&user_dir, "foo/SKILL.md", "foo", "a skill");

let skill_path = user_dir.join("foo/SKILL.md").to_string_lossy().to_string();
// Native separators + TOML literal string for the path: the rule match is
// exact string equality against the scanner's path, and a Windows path in
// a basic TOML string fails to parse (`\U` reads as a unicode escape).
let skill_path = user_dir
.join("foo")
.join("SKILL.md")
.to_string_lossy()
.to_string();
let config_path = tmp.path().join("config.toml");
std::fs::write(
&config_path,
format!(
"[[skills.config]]\npath = \"{}\"\nenabled = false\n",
"[[skills.config]]\npath = '{}'\nenabled = false\n",
skill_path
),
)
Expand Down Expand Up @@ -2227,15 +2234,18 @@ mod codex_skill_tests {
let bundled_dir = tmp.path().join("bundled");
write_skill(&bundled_dir, "sys/SKILL.md", "sys-skill", "built-in");

// Native separators + TOML literal string for the path: see
// test_codex_skill_enabled_path_rule.
let skill_path = bundled_dir
.join("sys/SKILL.md")
.join("sys")
.join("SKILL.md")
.to_string_lossy()
.to_string();
let config_path = tmp.path().join("config.toml");
std::fs::write(
&config_path,
format!(
"[skills.bundled]\nenabled = false\n\n[[skills.config]]\npath = \"{}\"\nenabled = true\n",
"[skills.bundled]\nenabled = false\n\n[[skills.config]]\npath = '{}'\nenabled = true\n",
skill_path
),
).unwrap();
Expand Down Expand Up @@ -2297,14 +2307,19 @@ mod codex_skill_tests {
write_skill(&skills_dir, "my-skill/SKILL.md", "my-skill", "desc");

let config_path = tmp.path().join("config.toml");
let skill_path = skills_dir.join("my-skill/SKILL.md");
// Native separators end-to-end: the production rule match is exact string
// equality against the scanner's path, and `join("a/b")` would leave a
// mixed-separator string on Windows.
let skill_path = skills_dir.join("my-skill").join("SKILL.md");
let skill_path_str = skill_path.to_string_lossy().to_string();

// First disable
// First disable. TOML literal string (single quotes): a Windows path in a
// basic string reads `\U` as a unicode escape and the file fails to parse.
// Production is immune - it writes through toml_edit, which escapes.
std::fs::write(
&config_path,
format!(
"[[skills.config]]\npath = \"{}\"\nenabled = false\n",
"[[skills.config]]\npath = '{}'\nenabled = false\n",
skill_path_str
),
)
Expand Down Expand Up @@ -2337,14 +2352,16 @@ mod codex_skill_tests {
write_skill(&skills_dir, "my-skill/SKILL.md", "my-skill", "desc");

let config_path = tmp.path().join("config.toml");
let skill_path = skills_dir.join("my-skill/SKILL.md");
// Native separators + TOML literal string for the path: see
// test_codex_skill_toggle_enable_path.
let skill_path = skills_dir.join("my-skill").join("SKILL.md");
let skill_path_str = skill_path.to_string_lossy().to_string();

// Name rule disables it + a path entry also disables it
std::fs::write(
&config_path,
format!(
"[[skills.config]]\nname = \"my-skill\"\nenabled = false\n\n[[skills.config]]\npath = \"{}\"\nenabled = false\n",
"[[skills.config]]\nname = \"my-skill\"\nenabled = false\n\n[[skills.config]]\npath = '{}'\nenabled = false\n",
skill_path_str
),
).unwrap();
Expand Down
6 changes: 5 additions & 1 deletion src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@
let model = $state<string | null>(storedModel || null);
let input = $state("");
let stepsOpen = $state(true);
let extra = $state<{ role: "user" | "brain"; text: string }[]>([]);

Check warning on line 211 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'extra' is assigned a value but never used. Allowed unused vars must match /^_/u
let builderType = $state<Kind>("automation");
let buildInput = $state("");
let testState = $state<"idle" | "running" | "done">("idle");
Expand Down Expand Up @@ -743,7 +743,7 @@
let filesBoards = $state<any[]>([]);
let filesSites = $state<any[]>([]);
let filesLoading = $state(false);
let filesErr = $state<string | null>(null);

Check warning on line 746 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'filesErr' is assigned a value but never used. Allowed unused vars must match /^_/u
// the currently-open item — the chat session becomes aware of it, like the web agent
type CtxItem = {
type: "board" | "mini-site" | "memory" | "scratchpad";
Expand Down Expand Up @@ -1177,14 +1177,14 @@
: Math.min(hasCanvasPage ? chatWidth : Math.max(chatWidth, CHAT_WIDE), chatMaxW),
);
// real board viewer (get_board)
let boardMeta = $state<any>(null);

Check warning on line 1180 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'boardMeta' is assigned a value but never used. Allowed unused vars must match /^_/u
let boardDataset = $state("");

Check warning on line 1181 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'boardDataset' is assigned a value but never used. Allowed unused vars must match /^_/u
let brdRows = $state<any[]>([]);

Check warning on line 1182 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'brdRows' is assigned a value but never used. Allowed unused vars must match /^_/u
let boardLoading = $state(false);
let boardErr = $state<string | null>(null);

Check warning on line 1184 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'boardErr' is assigned a value but never used. Allowed unused vars must match /^_/u
let itemHtml = $state<string | null>(null); // rendered inline via <iframe srcdoc>
let webviewErr = $state<string | null>(null);
let itemMode = $state<"attached" | "expanded" | "minimized">("attached"); // how tall the artifact is

Check warning on line 1187 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'itemMode' is assigned a value but never used. Allowed unused vars must match /^_/u
let dataPanelOpen = $state(false); // board data side panel
let boardDataObj = $state<any>(null); // { datasets: { <name>: { schema, row_count, rows } } }
let dataDataset = $state("");
Expand Down Expand Up @@ -3622,7 +3622,7 @@
}
const toolIdx = new Map<string, number>(); // tool_use_id → index in messages (to mark done)

function friendlyTool(name: string): string {

Check warning on line 3625 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'friendlyTool' is defined but never used. Allowed unused vars must match /^_/u
if (!name) return "a tool";
if (name.startsWith("mcp__")) {
const parts = name.split("__");
Expand Down Expand Up @@ -3876,7 +3876,7 @@
const SESSION_CONTINUATIONS_KEY = "brains.sessionContinuations";
const AUTOMATIONS_KEY = "brains.automations";
const sessionSwatch = (key?: string) => SESSION_COLORS.find((c) => c.key === key);
const sessions = [

Check warning on line 3879 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'sessions' is assigned a value but never used. Allowed unused vars must match /^_/u
{ id: "s1", title: "Q3 revenue recap", time: "now", type: "chat" },
{ id: "s2", title: "AI Twitter Radar", time: "2h", type: "automation" },
{ id: "s3", title: "Stripe → Notion sync", time: "1d", type: "integration" },
Expand All @@ -3885,7 +3885,7 @@
{ id: "s6", title: "Board cleanup", time: "5d", type: "chat" },
];

const revData: [number, number][] = [

Check warning on line 3888 in src/routes/+page.svelte

View workflow job for this annotation

GitHub Actions / Frontend

'revData' is assigned a value but never used. Allowed unused vars must match /^_/u
[45, 0],
[62, 0],
[54, 0],
Expand Down Expand Up @@ -7863,7 +7863,11 @@
if (!isApp || sending) return;
try {
const verdict = await isVendorSignedOut();
if (verdict === null) return; // couldn't tell - leave the UI exactly as it is
// Couldn't tell - leave the UI exactly as it is, INCLUDING signedOutStreak.
// Deliberate: an indeterminate probe is not evidence of being signed in, so
// it must not reset the count; two strikes may therefore span an
// indeterminate probe between them.
if (verdict === null) return;
const signedOut = verdict;
if (signedOut) {
// Two strikes. One slow CLI spawn must not raise a card that blocks
Expand Down
Loading