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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ from their close control, with Delete/Backspace while focused, or by middle-clic
without changing local refs or `FETCH_HEAD`. Active PRs can be closed from a
confirmed overflow action. Closed PRs can be reopened;
merged PRs and every terminal discussion/thread surface remain read-only.
Completed Azure PR Code views remain available after the provider deletes
the source branch by using its recorded target and merge commits.
GitHub and Azure DevOps Services authentication stays in the signed-in `gh` /
`az` CLI. Azure DevOps Server 2020+ is available through an optional,
independently updated, signed `strand-azdo` REST helper configured in Settings → Hosting;
Expand Down Expand Up @@ -218,8 +220,9 @@ from their close control, with Delete/Backspace while focused, or by middle-clic
Generation is cancellable, scans conservative sensitive-file
signals before provider launch, keeps the generated description content-sized,
and can retry explicitly with the other provider without changing your
default. Repository-family writing profiles keep terminology and style
consistent across worktrees.
default. Provider failures are reduced to concise, actionable hints; raw CLI
session, prompt, and patch transcripts are never displayed. Repository-family
writing profiles keep terminology and style consistent across worktrees.
- **Fast by design** — reads go through [gix](https://github.com/GitoxideLabs/gitoxide),
writes through git2 and your system `git`. Performance targets live in
[`PRD.md`](./PRD.md) §8 and are measured in
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2295,6 +2295,14 @@ only `v1.1.0`. Universal macOS notarization returned `Accepted` (request
Authenticode `NotSigned`, and the Git tag has no cryptographic signature, so
the external publisher and remaining release-checklist gates stay open.

**Linear bug sweep completed (2026-07-24):** DAN-24/25/26 are resolved.
Completed Azure PR Code views now use durable target/merge history, so
source-branch cleanup cannot blank the patch. AI vendor failures are reduced to
bounded, actionable messages and transcript-shaped output never exposes echoed
prompt or patch content. A real deleted-branch Git integration test covers the
Azure path, and a live WebView2 pass confirmed Pierre uses its light palette
when Strand resolves the system theme to light.

---

## Cross-cutting tracks (run in parallel with all milestones)
Expand Down
11 changes: 10 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1552,7 +1552,11 @@ tree: watch the agent work, review fast, accept or reject safely.
diff; line totals stay in the selected-file header rather than crowding
every tree row. The file header exposes the same
persisted stacked/split controls in context. Azure comparisons
fetch source/target objects without updating repository refs or FETCH_HEAD.
fetch source/target objects without updating repository refs or FETCH_HEAD;
completed Azure PRs reconstruct the historical patch from
`lastMergeTargetCommit` → `lastMergeCommit`, so provider source-branch
cleanup cannot break Code (`completed_azure_diff_survives_a_deleted_source_branch`,
DAN-24).
- ☑ 1.0 discussion threads and comment creation: Timeline reads GitHub
issue comments, GitHub review-thread comments, and Azure thread comments
(including inline file context) as safe
Expand Down Expand Up @@ -2105,6 +2109,11 @@ extraction above as prerequisite. **Do not start before 1.0 ships**
- ☑ Broken vendor-CLI installs stay distinct from signed-out sessions
(`AiProviderStatus.error`, auth-failure classification, and `--version`
login preflight prevent false “browser opened” messages)
- ☑ Vendor CLI failures become bounded, actionable messages instead of raw
session transcripts (`user_facing_cli_failure`, DAN-26): known rate-limit,
quota/billing, model, context, timeout, and network failures are classified;
prompt/patch text after vendor transcript boundaries is never used for
classification or shown to the user.
- ☑ AI provider execution hardened and cancellable (`AiProviderAdapter`,
canonical CLI resolution, isolated read-only Codex cwd/argv, bounded
stdout/stderr, process-group/Windows Job Object teardown, and shared
Expand Down
66 changes: 31 additions & 35 deletions crates/strand-tauri/src/ai/bin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,49 +660,45 @@ mod tests {
assert!(err.contains("timed out"), "unexpected error: {err}");
}

#[cfg(not(windows))]
#[test]
fn run_capture_cancels_process_group() {
fn assert_process_group_cancelled_promptly(
program: &'static str,
args: &'static [&'static str],
) {
let cancel = AiCancelHandle::new();
let trigger = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
trigger.cancel();
let worker_cancel = cancel.clone();
let (started_tx, started_rx) = std::sync::mpsc::sync_channel(0);
let worker = std::thread::spawn(move || {
started_tx.send(()).unwrap();
run_capture_cancellable(
Path::new(program),
args,
None,
None,
SUGGEST_TIMEOUT,
Some(&worker_cancel),
)
});
let started = Instant::now();
let err = run_capture_cancellable(
Path::new("/bin/sh"),
&["-c", "sleep 30 & wait"],
None,
None,
SUGGEST_TIMEOUT,
Some(&cancel),
)
.unwrap_err();
started_rx.recv().unwrap();
std::thread::sleep(Duration::from_millis(100));
let cancelled_at = Instant::now();
cancel.cancel();
let err = worker.join().unwrap().unwrap_err();
assert_eq!(err, "cancelled");
assert!(started.elapsed() < Duration::from_secs(3));
assert!(cancelled_at.elapsed() < Duration::from_secs(3));
}

#[cfg(not(windows))]
#[test]
fn run_capture_cancels_process_group() {
assert_process_group_cancelled_promptly("/bin/sh", &["-c", "sleep 30 & wait"]);
}

#[cfg(windows)]
#[test]
fn run_capture_cancels_process_group() {
let cancel = AiCancelHandle::new();
let trigger = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
trigger.cancel();
});
let started = Instant::now();
let err = run_capture_cancellable(
Path::new("cmd.exe"),
assert_process_group_cancelled_promptly(
"cmd.exe",
&["/C", "ping", "-n", "30", "127.0.0.1"],
None,
None,
SUGGEST_TIMEOUT,
Some(&cancel),
)
.unwrap_err();
assert_eq!(err, "cancelled");
assert!(started.elapsed() < Duration::from_secs(3));
);
}
}
141 changes: 137 additions & 4 deletions crates/strand-tauri/src/ai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,84 @@ pub(crate) fn map_cli_failure(
provider_label: &str,
err: String,
) -> String {
let diagnostic = cli_diagnostic_prefix(&err);
if let Some(health_error) = &status.error {
health_error.clone()
} else if !status.logged_in || is_auth_failure(&err) {
} else if !status.logged_in || is_auth_failure(diagnostic) {
format!("{AI_AUTH_REQUIRED} {provider_label} is not signed in.")
} else {
err
user_facing_cli_failure(provider_label, diagnostic, diagnostic.len() == err.len())
}
}

fn cli_diagnostic_prefix(err: &str) -> &str {
["<untrusted-", "\nuser\n", "\nUser\n"]
.iter()
.filter_map(|marker| err.find(marker))
.min()
.map_or(err, |end| &err[..end])
}

fn user_facing_cli_failure(provider_label: &str, err: &str, allow_detail: bool) -> String {
let lower = err.to_ascii_lowercase();
if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429") {
return format!("{provider_label} is rate-limited right now. Wait a moment, then try again.");
}
if lower.contains("quota")
|| lower.contains("billing")
|| lower.contains("insufficient credits")
{
return format!(
"{provider_label} could not generate a suggestion because the provider quota or billing limit was reached."
);
}
if (lower.contains("model") && lower.contains("not found"))
|| lower.contains("unsupported model")
|| lower.contains("invalid model")
{
return format!(
"{provider_label} does not support the selected model. Choose another model in Settings → AI and try again."
);
}
if lower.contains("context length")
|| lower.contains("context window")
|| lower.contains("maximum context")
|| lower.contains("too many tokens")
{
return format!(
"{provider_label} could not fit these changes in its context window. Stage or select fewer changes, then try again."
);
}
if lower.contains("timed out") {
return format!("{provider_label} took too long to respond. Check your connection and try again.");
}
if lower.contains("network")
|| lower.contains("connection reset")
|| lower.contains("connection refused")
|| lower.contains("could not resolve host")
|| lower.contains("dns")
{
return format!("{provider_label} could not be reached. Check your connection and try again.");
}

// Vendor CLIs can emit their full session preamble and echoed stdin on a
// failed run. That stdin contains repository paths and patches, so never
// surface or persist an unclassified transcript. A short single-line
// diagnostic is safe and usually already actionable.
let detail = err.trim();
if allow_detail
&& !detail.is_empty()
&& detail.len() <= 240
&& !detail.contains(['\n', '\r'])
&& !detail.contains("<untrusted-")
{
return format!("{provider_label} could not generate a suggestion: {detail}");
}
format!(
"{provider_label} could not generate a suggestion. Check the selected model, provider status, and connection, then try again."
)
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AiProvider {
Expand Down Expand Up @@ -277,7 +346,7 @@ mod tests {
}

#[test]
fn map_cli_failure_preserves_error_when_logged_in() {
fn map_cli_failure_explains_rate_limits_when_logged_in() {
let status = AiProviderStatus {
provider: AiProvider::Anthropic,
installed: true,
Expand All @@ -286,7 +355,10 @@ mod tests {
error: None,
};
let err = map_cli_failure(&status, "Claude Code", "rate limited".into());
assert_eq!(err, "rate limited");
assert_eq!(
err,
"Claude Code is rate-limited right now. Wait a moment, then try again."
);
}

#[test]
Expand Down Expand Up @@ -316,6 +388,67 @@ mod tests {
assert!(err.contains("Settings → AI"));
}

#[test]
fn map_cli_failure_never_exposes_vendor_transcripts_or_prompts() {
let status = AiProviderStatus {
provider: AiProvider::Openai,
installed: true,
logged_in: true,
account_hint: None,
error: None,
};
let raw = "OpenAI Codex v0.144.3\nworkdir: C:\\repo\nsession id: secret\n\
<untrusted-patches>private source patch</untrusted-patches>";
let err = map_cli_failure(&status, "Codex", raw.into());
assert_eq!(
err,
"Codex could not generate a suggestion. Check the selected model, provider status, and connection, then try again."
);
assert!(!err.contains("C:\\repo"));
assert!(!err.contains("private source patch"));
}

#[test]
fn map_cli_failure_explains_an_invalid_model_without_echoing_the_transcript() {
let status = AiProviderStatus {
provider: AiProvider::Openai,
installed: true,
logged_in: true,
account_hint: None,
error: None,
};
let err = map_cli_failure(
&status,
"Codex",
"session preamble\nError: selected model not found\n<untrusted-patches>secret</untrusted-patches>".into(),
);
assert_eq!(
err,
"Codex does not support the selected model. Choose another model in Settings → AI and try again."
);
assert!(!err.contains("secret"));
}

#[test]
fn map_cli_failure_does_not_classify_words_echoed_from_the_prompt() {
let status = AiProviderStatus {
provider: AiProvider::Openai,
installed: true,
logged_in: true,
account_hint: None,
error: None,
};
let err = map_cli_failure(
&status,
"Codex",
"session preamble\n<untrusted-patches>not logged in; invalid model; rate limit</untrusted-patches>".into(),
);
assert_eq!(
err,
"Codex could not generate a suggestion. Check the selected model, provider status, and connection, then try again."
);
}

#[test]
fn truncates_recent_subjects_on_utf8_boundaries() {
let subject = "é".repeat(100);
Expand Down
Loading
Loading