mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
fix(tui): repair undo identity, child approvals, plan handoff, OCR test
Land the four 0.10.2 release repairs from the frozen review batch. The source reviews passed. This commit adds local compile and focused-namespace evidence, not hosted CI. Hosted CI on this head is the full proof. Repairs: - Undo (commands/contract/debug_operations.rs, commands/debug_mutation_host_tests.rs): refuse ambiguous, repeated, truncated, multiline, missing or multipart request labels instead of matching display snippets. Exactly one held match is required, and force cannot bypass identity checks. Addresses the P1 review on debug_operations.rs:245-251. - Child approvals (core/engine/handle.rs, child_host.rs, tools/subagent/*): keep ApprovalDecider through the child Approved and Denied outcomes, so receipts attribute the actual decider. A timeout stays a denial, attributed to Host. Consumers migrated. Managed Computer Use consent authority is unchanged. - Plan handoff (tui/plan_handoff.rs, tui/app.rs, tui/app/init.rs, tui/ui/*, docs/GUIDE.md, docs/MODES.md, docs/zh_hans/GUIDE.md): successful nonempty Plan prose offers handoff and queues its exact text once. Failed preparation rolls back only an unchanged owned seed. Refs #6902. Addresses the P2 review on plan_handoff.rs:48. - OCR and CI (tools/image_ocr/host_tests.rs, .config/nextest.toml, .github/workflows/ci.yml): the cancellation fixture waits on an explicit descendant latch and observes cleanup, instead of racing a one-second write. OCR and PDF host tests join the max-threads-1 group. macOS test compilation sets CARGO_PROFILE_TEST_DEBUG=0 before cache setup. Local evidence (macOS arm64, debug, library tests; not CI): - cargo test -p codewhale-tui --lib --locked --no-run -j 2: exit 0, 0 errors, one linker note. - With RUST_MIN_STACK=8388608 (the workflow value in ci.yml): plan_handoff 16 passed, 0 failed; core::engine::tests::child_host 19 passed, 0 failed. - With the 2 MiB default test-thread stack: plan_handoff 15 of 16 and child_host 10 of 19 pass. The rest overflow the stack in debug builds. Measured stack need is 4 to 8 MiB. All nine child_host tests exist at HEAD and passed HEAD's CI on Linux and macOS. - debug_mutation_host_tests 38 of 38; the approval routing test an_agent_approval_answer_reaches_the_agent_while_the_engine_is_busy 1 of 1; image_ocr::host_tests 8 of 8, including real_host_ocr_cancellation_bounds_parser_tree_and_never_replays. - cargo fmt -p codewhale-tui -- --check and git diff --check: clean. Not run here: the full 19k-test suite, nextest, Windows, doctests, and hosted macOS timing. The previous macOS job exceeded 2h45m. Limits, as reviewed: checkpoint admission is queued acceptance, not disk durability, and an accepted queued checkpoint cannot be recalled. Pending Plan ownership is ephemeral. No restart recovery or permanent custody is claimed. Signed-off-by: CodeWhale Bot <[email protected]> Co-Authored-By: Claude Haiku 5.5 <[email protected]>
This commit is contained in:
1 parent
5392b9a3b0
commit
1bddf50a7e
23 files changed
+1618
-269
No files matched your search
@@ -78,8 +78,9 @@ test-group = 'fleet-manager-lifecycle'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
# DSH preparation also launches the isolated host against the shared hermetic
|
||||
# home. Keep its ACL grants/retirement in this existing serial lane.
|
||||
filter = 'package(codewhale-tui) & kind(lib) & (test(/^extension_host::/) | test(/^core::engine::.*extension/) | test(/^plugins::install::dsh::/) | test(/^plugins::tests::dsh_/) | test(/^runtime_api::tests::dsh_package_preview_then_exact_install_over_http$/) | test(/^commands::contract::tests::plugin_adapter_dsh_preview_runs_from_live_runtime$/) | test(/^commands::groups::plugins::tests::dsh_import_reviews_without_installing_then_installs_the_exact_bundle$/))'
|
||||
# home. OCR/PDF consumers also launch real hosts outside extension_host's
|
||||
# namespace. Keep their process lifecycle checks in this existing serial lane.
|
||||
filter = 'package(codewhale-tui) & kind(lib) & (test(/^extension_host::/) | test(/^tools::image_ocr::host_tests::/) | test(/^tools::pdf::host_tests::/) | test(/^core::engine::.*extension/) | test(/^plugins::install::dsh::/) | test(/^plugins::tests::dsh_/) | test(/^runtime_api::tests::dsh_package_preview_then_exact_install_over_http$/) | test(/^commands::contract::tests::plugin_adapter_dsh_preview_runs_from_live_runtime$/) | test(/^commands::groups::plugins::tests::dsh_import_reviews_without_installing_then_installs_the_exact_bundle$/))'
|
||||
test-group = 'extension-host'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
|
||||
@@ -867,6 +867,12 @@ jobs:
|
||||
run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
- name: Omit macOS test debug metadata
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
# Match the existing full-workspace CNB gate. Keep debug assertions and
|
||||
# every test; only disposable test debug tables change on this lane.
|
||||
run: echo "CARGO_PROFILE_TEST_DEBUG=0" >> "$GITHUB_ENV"
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
continue-on-error: true
|
||||
|
||||
@@ -206,16 +206,15 @@ fn session_sync_payload(app: &App) -> SessionSyncPayload {
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompts of this conversation's requests, oldest first, as a restore
|
||||
/// point's label carries them (`None` for a request without text).
|
||||
fn request_snippets(app: &App) -> Vec<Option<String>> {
|
||||
use crate::core::turn::snapshot_label_prompt_snippet;
|
||||
/// Full text of this conversation's requests, oldest first. Missing text is
|
||||
/// retained: an image-only request is a boundary, not a request to skip.
|
||||
fn request_prompts(app: &App) -> Vec<Option<String>> {
|
||||
if app.api_messages.is_empty() {
|
||||
return app
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|cell| match cell {
|
||||
HistoryCell::User { content } => Some(snapshot_label_prompt_snippet(content)),
|
||||
HistoryCell::User { content } => Some(Some(content.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -229,27 +228,76 @@ fn request_snippets(app: &App) -> Vec<Option<String>> {
|
||||
)
|
||||
})
|
||||
.map(|message| {
|
||||
crate::receipts::prompt_text(message)
|
||||
.as_deref()
|
||||
.and_then(snapshot_label_prompt_snippet)
|
||||
let meta_index =
|
||||
crate::runtime_handoff::turn_metadata_text(message).map(|(index, _)| index);
|
||||
let mut content = message
|
||||
.content
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| Some(*index) != meta_index);
|
||||
// A text label cannot identify the rest of a multipart request.
|
||||
match (content.next(), content.next()) {
|
||||
(Some((_, codewhale_models::ContentBlock::Text { text, .. })), None) => {
|
||||
Some(text.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where the request that restore point `label` starts sits in the
|
||||
/// conversation: its last request (`Some(true)`), an earlier one
|
||||
/// (`Some(false)`), or no longer in it (`None`).
|
||||
fn request_is_last(app: &App, label: &str) -> Option<bool> {
|
||||
let request = crate::core::turn::parse_snapshot_label(label).prompt_snippet;
|
||||
let requests = request_snippets(app);
|
||||
// A label without a prompt cannot be matched: it is the last request's.
|
||||
if request.is_none() || requests.last() == Some(&request) {
|
||||
Some(true)
|
||||
} else if requests.contains(&request) {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
/// Match only lossless, unique prompt labels. Display snippets are not durable
|
||||
/// request identities: repeated, multiline, truncated or multipart prompts cannot
|
||||
/// authorize a combined file/conversation rollback. `/restore` remains explicit.
|
||||
fn request_is_last(
|
||||
requests: &[Option<String>],
|
||||
owned_request_snippets: &[Option<String>],
|
||||
label: &str,
|
||||
) -> Result<Option<bool>, Box<DebugUndoOutcome>> {
|
||||
use crate::core::turn::{parse_snapshot_label, snapshot_label_prompt_snippet};
|
||||
if requests.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let refusal = || {
|
||||
Box::new(DebugUndoOutcome::RestoreBlocked(
|
||||
concat!(
|
||||
"Nothing was undone. This restore point cannot be matched safely to a request ",
|
||||
"in the conversation. Use /restore to choose a restore point explicitly.",
|
||||
)
|
||||
.to_string(),
|
||||
))
|
||||
};
|
||||
// A held request with missing or shortened text could be the candidate
|
||||
// even when its display label appears to name an older request.
|
||||
if requests.iter().any(|prompt| {
|
||||
prompt.as_deref().is_none_or(|prompt| {
|
||||
prompt.is_empty() || snapshot_label_prompt_snippet(prompt).as_deref() != Some(prompt)
|
||||
})
|
||||
}) {
|
||||
return Err(refusal());
|
||||
}
|
||||
let request = parse_snapshot_label(label).prompt_snippet;
|
||||
let Some(prompt) = request.as_deref().filter(|prompt| {
|
||||
!prompt.is_empty() && snapshot_label_prompt_snippet(prompt).as_deref() == Some(*prompt)
|
||||
}) else {
|
||||
return Err(refusal());
|
||||
};
|
||||
let matches = requests
|
||||
.iter()
|
||||
.filter(|candidate| candidate.as_deref() == Some(prompt))
|
||||
.count();
|
||||
if matches != 1
|
||||
|| owned_request_snippets
|
||||
.iter()
|
||||
.filter(|candidate| candidate.as_deref() == Some(prompt))
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
// Snapshots outlive conversation rewinds, so checking only the held
|
||||
// prompts would still confuse an older occurrence with the latest.
|
||||
return Err(refusal());
|
||||
}
|
||||
Ok(Some(requests.last() == Some(&request)))
|
||||
}
|
||||
|
||||
/// Deepest fork chain [`snapshot_owners`] follows. A chain this long is
|
||||
@@ -358,6 +406,8 @@ pub(in crate::commands) struct UndoStep {
|
||||
/// The step has no recorded end and runs only because the user typed
|
||||
/// `force`: it also puts back edits made since the request started.
|
||||
pub(in crate::commands) forced: bool,
|
||||
/// The validated request boundary, captured before any backup is taken.
|
||||
request_is_last: Option<bool>,
|
||||
}
|
||||
|
||||
/// Find the newest step of `snapshots` (newest first) that `owners` own and
|
||||
@@ -381,10 +431,11 @@ pub(in crate::commands) struct UndoStep {
|
||||
/// whatever was edited since; that one is refused unless the user typed
|
||||
/// `force`.
|
||||
///
|
||||
/// `newer_request_held` says whether the conversation still holds a request
|
||||
/// newer than the one a restore point's label names. Such a request left no
|
||||
/// file change to undo and comes off first (the caller's conversation-only
|
||||
/// undo), so the conversation never keeps a request whose files went back,
|
||||
/// `request_position` validates each non-no-op candidate before any backup or
|
||||
/// restore write and says whether it names the last held request. An earlier held request means
|
||||
/// the conversation still holds a request newer than this restore point. Such a
|
||||
/// request left no file change to undo and comes off first (the caller's
|
||||
/// conversation-only undo), so the conversation never keeps a request whose files went back,
|
||||
/// and a refusal about an older request never blocks taking a newer one off.
|
||||
///
|
||||
/// Planning writes nothing, except when the newest step ends now: the
|
||||
@@ -406,7 +457,7 @@ fn plan_undo_step(
|
||||
snapshots: Vec<crate::snapshot::Snapshot>,
|
||||
owners: &[SnapshotOwner],
|
||||
force: bool,
|
||||
newer_request_held: &dyn Fn(&str) -> bool,
|
||||
request_position: &dyn Fn(&str) -> Result<Option<bool>, Box<DebugUndoOutcome>>,
|
||||
) -> Result<UndoStep, Box<DebugUndoOutcome>> {
|
||||
let owned: Vec<crate::snapshot::Snapshot> = snapshots
|
||||
.into_iter()
|
||||
@@ -448,7 +499,10 @@ fn plan_undo_step(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if newer_request_held(&target.label) {
|
||||
// An open-ended step can write its backup while planning:
|
||||
// establish identity before force can take that snapshot.
|
||||
let request_is_last = request_position(&target.label)?;
|
||||
if request_is_last == Some(false) {
|
||||
return Err(Box::new(DebugUndoOutcome::NoDifference));
|
||||
}
|
||||
if !force {
|
||||
@@ -509,7 +563,10 @@ fn plan_undo_step(
|
||||
// cannot restore: keep walking back.
|
||||
continue;
|
||||
}
|
||||
if newer_request_held(&target.label) {
|
||||
// No-op and already-undone snapshots outlive conversation rewinds.
|
||||
// Only a candidate with remaining changes needs request identity.
|
||||
let request_is_last = request_position(&target.label)?;
|
||||
if request_is_last == Some(false) {
|
||||
return Err(Box::new(DebugUndoOutcome::NoDifference));
|
||||
}
|
||||
if !changed_since.is_empty() {
|
||||
@@ -529,6 +586,7 @@ fn plan_undo_step(
|
||||
skipped,
|
||||
backup,
|
||||
forced: !bounded,
|
||||
request_is_last,
|
||||
});
|
||||
}
|
||||
Err(Box::new(DebugUndoOutcome::NoDifference))
|
||||
@@ -571,7 +629,7 @@ pub(in crate::commands) fn active_turn_restore_refusal(app: &App) -> Option<Stri
|
||||
/// Trust mode and Full Access are not consulted: the restore is confined to
|
||||
/// regular files inside the workspace that the request itself changed.
|
||||
/// `force` (the user typed `/undo force`) accepts a request with no recorded
|
||||
/// end.
|
||||
/// end, never an ambiguous request identity.
|
||||
pub(in crate::commands) fn undo_files(app: &mut App, force: bool) -> DebugUndoOutcome {
|
||||
if let Some(refusal) = active_turn_restore_refusal(app) {
|
||||
return DebugUndoOutcome::RestoreBlocked(refusal);
|
||||
@@ -617,13 +675,20 @@ pub(in crate::commands) fn undo_files(app: &mut App, force: bool) -> DebugUndoOu
|
||||
return DebugUndoOutcome::NoSession;
|
||||
}
|
||||
|
||||
let newer_request_held = |label: &str| request_is_last(app, label) == Some(false);
|
||||
let step = match plan_undo_step(&repo, snapshots, &owners, force, &newer_request_held) {
|
||||
let requests = request_prompts(app);
|
||||
let owned_request_snippets: Vec<_> = snapshots
|
||||
.iter()
|
||||
.filter(|snapshot| is_undo_step_label(&snapshot.label))
|
||||
.filter(|snapshot| owners.iter().any(|owner| owner.owns(snapshot)))
|
||||
.map(|snapshot| crate::core::turn::parse_snapshot_label(&snapshot.label).prompt_snippet)
|
||||
.collect();
|
||||
let request_position = |label: &str| request_is_last(&requests, &owned_request_snippets, label);
|
||||
let step = match plan_undo_step(&repo, snapshots, &owners, force, &request_position) {
|
||||
Ok(step) => step,
|
||||
Err(outcome) => return *outcome,
|
||||
};
|
||||
let target = &step.target;
|
||||
let is_last_request = request_is_last(app, &target.label);
|
||||
let is_last_request = step.request_is_last;
|
||||
|
||||
let plan: Vec<(PathBuf, crate::snapshot::SnapshotId)> = step
|
||||
.restore
|
||||
|
||||
@@ -326,10 +326,10 @@ fn test_patch_undo_requests_session_resync_after_restore() {
|
||||
|
||||
let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
|
||||
std::fs::write(workspace.join("a.txt"), b"original").unwrap();
|
||||
repo.snapshot_with_session("pre-turn:1", Some("test-session"))
|
||||
repo.snapshot_with_session("pre-turn:1: please edit a.txt", Some("test-session"))
|
||||
.unwrap();
|
||||
std::fs::write(workspace.join("a.txt"), b"modified").unwrap();
|
||||
repo.snapshot_with_session("post-turn:1", Some("test-session"))
|
||||
repo.snapshot_with_session("post-turn:1: please edit a.txt", Some("test-session"))
|
||||
.unwrap();
|
||||
|
||||
let mut app = create_test_app();
|
||||
@@ -432,10 +432,10 @@ fn test_patch_undo_prunes_pre_turn_context() {
|
||||
let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
|
||||
let file = workspace.join("a.txt");
|
||||
std::fs::write(&file, b"alpha").unwrap();
|
||||
repo.snapshot_with_session("pre-turn:1", Some("test-session"))
|
||||
repo.snapshot_with_session("pre-turn:1: please edit a.txt", Some("test-session"))
|
||||
.unwrap();
|
||||
std::fs::write(&file, b"alpha-fixed").unwrap();
|
||||
repo.snapshot_with_session("post-turn:1", Some("test-session"))
|
||||
repo.snapshot_with_session("post-turn:1: please edit a.txt", Some("test-session"))
|
||||
.unwrap();
|
||||
|
||||
let mut app = create_test_app();
|
||||
@@ -890,6 +890,209 @@ fn undo_never_reverts_files_of_a_request_the_conversation_still_holds() {
|
||||
assert!(app.api_messages.is_empty());
|
||||
}
|
||||
|
||||
/// Identity refusals must reach the command as refusals, rather than its
|
||||
/// conversation-only fallback, and must run before a forced safety snapshot.
|
||||
fn assert_undo_identity_refused(fx: &UndoFixture, app: &mut App) {
|
||||
let messages = app.api_messages.as_ref().clone();
|
||||
let history_len = app.history.len();
|
||||
let snapshots: Vec<_> = fx
|
||||
.repo
|
||||
.list(usize::MAX)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
let contents = fx.read("a.txt");
|
||||
for command in ["/undo", "/undo force"] {
|
||||
let result = super::execute(command, app);
|
||||
assert!(
|
||||
result.message.as_deref().is_some_and(|message| {
|
||||
message
|
||||
.starts_with("Nothing was undone. This restore point cannot be matched safely")
|
||||
&& message.contains("/restore")
|
||||
}),
|
||||
"{command}: {:?}",
|
||||
result.message
|
||||
);
|
||||
assert!(result.action.is_none(), "no conversation-only fallback");
|
||||
assert_eq!(app.api_messages.as_ref(), &messages);
|
||||
assert_eq!(app.history.len(), history_len);
|
||||
assert_eq!(fx.read("a.txt"), contents);
|
||||
assert_eq!(
|
||||
fx.repo
|
||||
.list(usize::MAX)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| s.id)
|
||||
.collect::<Vec<_>>(),
|
||||
snapshots,
|
||||
"identity refusal creates no backup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_refuses_repeated_requests_even_after_a_conversation_rewind() {
|
||||
for rewind in [false, true] {
|
||||
let fx = UndoFixture::new();
|
||||
fx.write("a.txt", "a0");
|
||||
fx.snapshot("pre-turn:1: continue", "s1");
|
||||
fx.write("a.txt", "a1");
|
||||
fx.snapshot("post-turn:1: continue", "s1");
|
||||
fx.snapshot("pre-turn:2: continue", "s1");
|
||||
fx.snapshot("post-turn:2: continue", "s1");
|
||||
let mut app = fx.app("s1");
|
||||
push_exchange(&mut app, "continue");
|
||||
if !rewind {
|
||||
push_exchange(&mut app, "continue");
|
||||
}
|
||||
// Previously the no-op second request was skipped, then the first
|
||||
// request's files were restored while the last held exchange vanished.
|
||||
assert_undo_identity_refused(&fx, &mut app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_refuses_lossy_shared_prefix_and_first_line_labels() {
|
||||
let prefix = "x".repeat(100);
|
||||
for (first, second) in [
|
||||
(format!("{prefix} first"), format!("{prefix} second")),
|
||||
(
|
||||
"edit a.txt\nfirst change".into(),
|
||||
"edit a.txt\nsecond change".into(),
|
||||
),
|
||||
] {
|
||||
let fx = UndoFixture::new();
|
||||
fx.write("a.txt", "a0");
|
||||
fx.snapshot(
|
||||
&crate::core::turn::format_snapshot_label("pre-turn", 1, Some(&first)),
|
||||
"s1",
|
||||
);
|
||||
fx.write("a.txt", "a1");
|
||||
fx.snapshot(
|
||||
&crate::core::turn::format_snapshot_label("post-turn", 1, Some(&first)),
|
||||
"s1",
|
||||
);
|
||||
fx.snapshot(
|
||||
&crate::core::turn::format_snapshot_label("pre-turn", 2, Some(&second)),
|
||||
"s1",
|
||||
);
|
||||
fx.snapshot(
|
||||
&crate::core::turn::format_snapshot_label("post-turn", 2, Some(&second)),
|
||||
"s1",
|
||||
);
|
||||
let mut app = fx.app("s1");
|
||||
push_exchange(&mut app, &first);
|
||||
push_exchange(&mut app, &second);
|
||||
assert_undo_identity_refused(&fx, &mut app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_refuses_a_unique_lossy_prompt_even_without_a_collision() {
|
||||
for prompt in ["x".repeat(101), "edit a.txt\nwith a second line".into()] {
|
||||
let fx = UndoFixture::new();
|
||||
fx.write("a.txt", "a0");
|
||||
fx.snapshot(
|
||||
&crate::core::turn::format_snapshot_label("pre-turn", 1, Some(&prompt)),
|
||||
"s1",
|
||||
);
|
||||
fx.write("a.txt", "a1");
|
||||
fx.snapshot(
|
||||
&crate::core::turn::format_snapshot_label("post-turn", 1, Some(&prompt)),
|
||||
"s1",
|
||||
);
|
||||
let mut app = fx.app("s1");
|
||||
push_exchange(&mut app, &prompt);
|
||||
assert_undo_identity_refused(&fx, &mut app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_refuses_missing_prompt_labels_and_multipart_requests() {
|
||||
for kind in 0..4 {
|
||||
let fx = UndoFixture::new();
|
||||
fx.write("a.txt", "a0");
|
||||
fx.snapshot(
|
||||
if kind == 0 {
|
||||
"pre-turn:1"
|
||||
} else {
|
||||
"pre-turn:1: edit a.txt"
|
||||
},
|
||||
"s1",
|
||||
);
|
||||
fx.write("a.txt", "a1");
|
||||
// Leave the end open so /undo force would otherwise write a backup.
|
||||
let mut app = fx.app("s1");
|
||||
if kind == 0 {
|
||||
push_exchange(&mut app, "edit a.txt");
|
||||
} else {
|
||||
let image = ContentBlock::ImageUrl {
|
||||
image_url: codewhale_models::ImageUrlContent {
|
||||
url: "data:image/png;base64,AAAA".into(),
|
||||
},
|
||||
};
|
||||
let text = ContentBlock::Text {
|
||||
text: "edit a.txt".into(),
|
||||
cache_control: None,
|
||||
};
|
||||
app.api_messages_mut().push(Message {
|
||||
role: Role::User,
|
||||
content: match kind {
|
||||
1 => vec![image],
|
||||
2 => vec![text, image],
|
||||
_ => vec![
|
||||
text,
|
||||
ContentBlock::Text {
|
||||
text: "second instruction".into(),
|
||||
cache_control: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
assert_undo_identity_refused(&fx, &mut app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_identity_guard_includes_history_only_requests() {
|
||||
let fx = UndoFixture::new();
|
||||
fx.write("a.txt", "a0");
|
||||
fx.snapshot("pre-turn:1: continue", "s1");
|
||||
fx.write("a.txt", "a1");
|
||||
fx.snapshot("post-turn:1: continue", "s1");
|
||||
let mut app = fx.app("s1");
|
||||
for _ in 0..2 {
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "continue".into(),
|
||||
});
|
||||
}
|
||||
assert_undo_identity_refused(&fx, &mut app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_keeps_lossless_hundred_character_unicode_prompts_available() {
|
||||
let fx = UndoFixture::new();
|
||||
let prompt = "漢".repeat(100);
|
||||
fx.write("a.txt", "a0");
|
||||
fx.snapshot(
|
||||
&crate::core::turn::format_snapshot_label("pre-turn", 1, Some(&prompt)),
|
||||
"s1",
|
||||
);
|
||||
fx.write("a.txt", "a1");
|
||||
fx.snapshot(
|
||||
&crate::core::turn::format_snapshot_label("post-turn", 1, Some(&prompt)),
|
||||
"s1",
|
||||
);
|
||||
let mut app = fx.app("s1");
|
||||
push_exchange(&mut app, &prompt);
|
||||
let result = super::execute("/undo", &mut app);
|
||||
assert!(!result.is_error, "{:?}", result.message);
|
||||
assert_eq!(fx.read("a.txt"), "a0");
|
||||
assert!(app.history.is_empty() && app.api_messages.is_empty());
|
||||
}
|
||||
|
||||
/// A file the older request changed was edited since. That refusal belongs
|
||||
/// to the older request: the newer one, which changed no files, still comes
|
||||
/// off first, and the refusal is shown once the older request is the last.
|
||||
@@ -950,11 +1153,10 @@ fn undo_takes_a_newer_request_off_before_refusing_an_older_one() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Esc Esc rewinds the conversation and leaves the files. `/undo` afterwards
|
||||
/// puts that request's files back and leaves the older conversation alone,
|
||||
/// and the message says the conversation was not changed.
|
||||
/// A restore point absent from a held conversation has no proven request
|
||||
/// identity, even when both display labels are distinct and lossless.
|
||||
#[test]
|
||||
fn undo_after_a_conversation_rewind_restores_files_and_keeps_the_conversation() {
|
||||
fn undo_refuses_an_unmatched_restore_point_after_a_partial_conversation_rewind() {
|
||||
let fx = UndoFixture::new();
|
||||
fx.write("a.txt", "a0");
|
||||
fx.snapshot("pre-turn:1: say hello", "s1");
|
||||
@@ -962,21 +1164,28 @@ fn undo_after_a_conversation_rewind_restores_files_and_keeps_the_conversation()
|
||||
fx.snapshot("pre-turn:2: edit a.txt", "s1");
|
||||
fx.write("a.txt", "a1");
|
||||
fx.snapshot("post-turn:2: edit a.txt", "s1");
|
||||
|
||||
let mut app = fx.app("s1");
|
||||
// The rewind already dropped "edit a.txt"; "say hello" is what is left.
|
||||
push_exchange(&mut app, "say hello");
|
||||
assert_undo_identity_refused(&fx, &mut app);
|
||||
}
|
||||
|
||||
/// File-only undo still works after all requests have been rewound away.
|
||||
#[test]
|
||||
fn undo_after_a_full_conversation_rewind_restores_files_without_messages() {
|
||||
let fx = UndoFixture::new();
|
||||
fx.write("a.txt", "a0");
|
||||
fx.snapshot("pre-turn:1: edit a.txt", "s1");
|
||||
fx.write("a.txt", "a1");
|
||||
fx.snapshot("post-turn:1: edit a.txt", "s1");
|
||||
let mut app = fx.app("s1");
|
||||
let result = super::execute("/undo", &mut app);
|
||||
|
||||
assert_eq!(fx.read("a.txt"), "a0", "{:?}", result.message);
|
||||
assert_eq!(app.api_messages.len(), 2, "the older exchange stays");
|
||||
assert_eq!(app.history.len(), 2);
|
||||
assert_eq!(
|
||||
result.message.as_deref(),
|
||||
Some(
|
||||
"Undid your request \"edit a.txt\". 1 file is back as it was before it:\n restored a.txt\nThe conversation was not changed."
|
||||
)
|
||||
assert!(app.api_messages.is_empty() && app.history.is_empty());
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("The conversation was not changed."))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -681,8 +681,8 @@ impl Engine {
|
||||
handle.cancel();
|
||||
} else {
|
||||
match decision {
|
||||
Ok(crate::tools::subagent::ChildApprovalOutcome::Approved) => handle.approve_tool_call(id).await?,
|
||||
Ok(crate::tools::subagent::ChildApprovalOutcome::Denied) => handle.deny_tool_call(id).await?,
|
||||
Ok(crate::tools::subagent::ChildApprovalOutcome::Approved { by }) => handle.approve_tool_call_by(id, by).await?,
|
||||
Ok(crate::tools::subagent::ChildApprovalOutcome::Denied { by }) => handle.deny_tool_call_by(id, by).await?,
|
||||
_ => handle.deny_tool_call_unavailable(id).await?,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,11 +590,20 @@ impl EngineHandle {
|
||||
async fn send_approval(&self, decision: ApprovalDecision) -> Result<()> {
|
||||
use crate::tools::subagent::ChildApprovalOutcome;
|
||||
let child = match &decision {
|
||||
ApprovalDecision::Approved { id, .. } => Some((id, ChildApprovalOutcome::Approved)),
|
||||
ApprovalDecision::Denied { id, .. } => Some((id, ChildApprovalOutcome::Denied)),
|
||||
ApprovalDecision::Approved { id, by } => {
|
||||
Some((id, ChildApprovalOutcome::Approved { by: *by }))
|
||||
}
|
||||
ApprovalDecision::Denied { id, by } => {
|
||||
Some((id, ChildApprovalOutcome::Denied { by: *by }))
|
||||
}
|
||||
// An agent has no timeout outcome of its own (#6101): an expired
|
||||
// card is a deny for whichever call it was answering.
|
||||
ApprovalDecision::TimedOut { id } => Some((id, ChildApprovalOutcome::Denied)),
|
||||
ApprovalDecision::TimedOut { id } => Some((
|
||||
id,
|
||||
ChildApprovalOutcome::Denied {
|
||||
by: ApprovalDecider::Host,
|
||||
},
|
||||
)),
|
||||
ApprovalDecision::Unavailable { id } => Some((id, ChildApprovalOutcome::Unavailable)),
|
||||
// A sandbox retry only exists for the parent's own tool call.
|
||||
ApprovalDecision::RetryWithPolicy { .. } => None,
|
||||
|
||||
@@ -583,10 +583,17 @@ async fn queued_child_cancel_preserves_checkpoint_without_dispatching_a_fresh_re
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn actual_child_transport_services_approval_and_cancel_with_saturated_steer_queue() {
|
||||
use crate::approval_log::{ApprovalDecider, ApprovalOutcome};
|
||||
use crate::llm_client::mock::canned;
|
||||
use crate::tools::subagent::engine::{drive_child_actor, send_test_child_input};
|
||||
use crate::tools::subagent::{ChildApprovalOutcome, SubAgentStatus};
|
||||
for cancel in [false, true] {
|
||||
for (cancel, by, approved) in [
|
||||
(false, ApprovalDecider::User, true),
|
||||
(false, ApprovalDecider::SessionRule, true),
|
||||
(false, ApprovalDecider::Posture, false),
|
||||
(false, ApprovalDecider::Host, false),
|
||||
(true, ApprovalDecider::User, true),
|
||||
] {
|
||||
let dir = tempdir().unwrap();
|
||||
let _home = crate::test_support::SealedHome::at(dir.path());
|
||||
let (_old, _old_handle, captured, _) = fixture(dir.path(), Some(vec!["bash".into()]));
|
||||
@@ -619,6 +626,8 @@ async fn actual_child_transport_services_approval_and_cancel_with_saturated_stee
|
||||
4,
|
||||
)
|
||||
.await;
|
||||
let receipt_store = core.approval_receipt_store.clone().unwrap();
|
||||
let receipt_session = core.session.id.clone();
|
||||
let manager = job.authority.runtime.manager.clone();
|
||||
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let pending = Arc::new(std::sync::atomic::AtomicUsize::new(2));
|
||||
@@ -667,11 +676,16 @@ async fn actual_child_transport_services_approval_and_cancel_with_saturated_stee
|
||||
if cancel {
|
||||
job.authority.runtime.cancel_token.cancel();
|
||||
} else {
|
||||
let answer = if approved {
|
||||
ChildApprovalOutcome::Approved { by }
|
||||
} else {
|
||||
ChildApprovalOutcome::Denied { by }
|
||||
};
|
||||
assert!(
|
||||
manager
|
||||
.write()
|
||||
.await
|
||||
.resolve_child_approval(&approval_id, ChildApprovalOutcome::Approved)
|
||||
.resolve_child_approval(&approval_id, answer)
|
||||
);
|
||||
}
|
||||
drop(input_tx);
|
||||
@@ -720,11 +734,27 @@ async fn actual_child_transport_services_approval_and_cancel_with_saturated_stee
|
||||
assert_eq!(mock.call_count(), 1);
|
||||
} else {
|
||||
assert_eq!(result.status, SubAgentStatus::Completed);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("effect.txt")).unwrap(),
|
||||
"child"
|
||||
);
|
||||
if approved {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("effect.txt")).unwrap(),
|
||||
"child"
|
||||
);
|
||||
} else {
|
||||
assert!(!dir.path().join("effect.txt").exists());
|
||||
}
|
||||
assert_eq!(mock.call_count(), 2);
|
||||
let replay = receipt_store.replay(&receipt_session).unwrap();
|
||||
assert!(replay.unmatched_asks.is_empty());
|
||||
assert_eq!(replay.completed.len(), 1);
|
||||
assert_eq!(replay.completed[0].decided_by, Some(by));
|
||||
assert_eq!(
|
||||
replay.completed[0].outcome,
|
||||
if approved {
|
||||
ApprovalOutcome::ApprovedOnce
|
||||
} else {
|
||||
ApprovalOutcome::Denied
|
||||
}
|
||||
);
|
||||
}
|
||||
assert!(result.checkpoint.is_some());
|
||||
let checkpoint = result.checkpoint.as_ref().unwrap();
|
||||
|
||||
@@ -118,7 +118,9 @@ async fn an_agent_approval_answer_reaches_the_agent_while_the_engine_is_busy() {
|
||||
.expect("answer");
|
||||
assert_eq!(
|
||||
outcome,
|
||||
crate::tools::subagent::ChildApprovalOutcome::Approved
|
||||
crate::tools::subagent::ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ fn repeated_provider_id_ask_rejects_stale_answer_and_keeps_local_artifact_origin
|
||||
.await
|
||||
.expect("queued decisions are consumed")
|
||||
.unwrap(),
|
||||
ChildApprovalOutcome::Approved
|
||||
ChildApprovalOutcome::Approved { by: crate::approval_log::ApprovalDecider::User }
|
||||
);
|
||||
let receipts = receipt_store.load(&session_id).unwrap();
|
||||
assert_eq!(receipts.len(), 3, "stale answer cannot decide the second Ask");
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_boot_catalog_refresh_declares_prefix_before_mailbox_delivery() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
@@ -929,11 +927,12 @@ async fn user_prompt_reaches_the_model_exactly_once_per_request() {
|
||||
task.await.expect("engine task");
|
||||
}
|
||||
|
||||
/// A person's answer to a prompt raised for a child (`agent:…:approval:n`)
|
||||
/// An attributed answer to a prompt raised for a child (`agent:…:approval:n`)
|
||||
/// reaches the waiting child while the parent turn is idle; the parent's
|
||||
/// own approval path is untouched by ids it does not own.
|
||||
#[tokio::test]
|
||||
async fn idle_engine_routes_child_approval_decisions_to_the_waiting_child() {
|
||||
use crate::approval_log::ApprovalDecider;
|
||||
use crate::tools::subagent::ChildApprovalOutcome;
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let config = EngineConfig {
|
||||
@@ -945,28 +944,40 @@ async fn idle_engine_routes_child_approval_decisions_to_the_waiting_child() {
|
||||
let manager = engine.subagent_manager.clone();
|
||||
let run = tokio::spawn(engine.run());
|
||||
|
||||
let (approval_id, receiver) = manager
|
||||
.write()
|
||||
.await
|
||||
.register_child_approval(
|
||||
"agent_child",
|
||||
&format!("agent:agent_child:approval:{}", uuid::Uuid::new_v4()),
|
||||
"bash",
|
||||
"fixture",
|
||||
)
|
||||
.unwrap();
|
||||
handle
|
||||
.approve_tool_call(approval_id.clone())
|
||||
.await
|
||||
.expect("approval decision accepted");
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(5), receiver)
|
||||
.await
|
||||
.expect("child must be answered while the engine idles")
|
||||
.expect("child prompt resolved, not dropped");
|
||||
assert_eq!(outcome, ChildApprovalOutcome::Approved);
|
||||
assert_eq!(manager.read().await.pending_child_approvals(), 0);
|
||||
for by in [
|
||||
ApprovalDecider::User,
|
||||
ApprovalDecider::SessionRule,
|
||||
ApprovalDecider::Posture,
|
||||
ApprovalDecider::Host,
|
||||
] {
|
||||
for approved in [true, false] {
|
||||
let (approval_id, receiver) = manager
|
||||
.write()
|
||||
.await
|
||||
.register_child_approval(
|
||||
"agent_child",
|
||||
&format!("agent:agent_child:approval:{}", uuid::Uuid::new_v4()),
|
||||
"bash",
|
||||
"fixture",
|
||||
)
|
||||
.unwrap();
|
||||
let expected = if approved {
|
||||
handle.approve_tool_call_by(approval_id, by).await.unwrap();
|
||||
ChildApprovalOutcome::Approved { by }
|
||||
} else {
|
||||
handle.deny_tool_call_by(approval_id, by).await.unwrap();
|
||||
ChildApprovalOutcome::Denied { by }
|
||||
};
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(5), receiver)
|
||||
.await
|
||||
.expect("child must be answered while the engine idles")
|
||||
.expect("child prompt resolved, not dropped");
|
||||
assert_eq!(outcome, expected);
|
||||
assert_eq!(manager.read().await.pending_child_approvals(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// A denial for a second prompt routes the same way.
|
||||
// An expired card is still a denial for a child, attributed to the host.
|
||||
let (approval_id, receiver) = manager
|
||||
.write()
|
||||
.await
|
||||
@@ -977,15 +988,16 @@ async fn idle_engine_routes_child_approval_decisions_to_the_waiting_child() {
|
||||
"fixture",
|
||||
)
|
||||
.unwrap();
|
||||
handle
|
||||
.deny_tool_call(approval_id)
|
||||
.await
|
||||
.expect("denial accepted");
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(5), receiver)
|
||||
.await
|
||||
.expect("child must be answered")
|
||||
.expect("child prompt resolved");
|
||||
assert_eq!(outcome, ChildApprovalOutcome::Denied);
|
||||
handle.deny_tool_call_timed_out(approval_id).await.unwrap();
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), receiver)
|
||||
.await
|
||||
.expect("child timeout is delivered")
|
||||
.expect("child prompt resolved"),
|
||||
ChildApprovalOutcome::Denied {
|
||||
by: ApprovalDecider::Host,
|
||||
}
|
||||
);
|
||||
|
||||
// A decision for a parent-shaped id has no child waiter and is not routed.
|
||||
assert!(!crate::tools::subagent::SubAgentManager::is_child_approval_id("call_123"));
|
||||
@@ -1222,4 +1234,4 @@ fn engine_adopts_host_owned_session_id_from_config() {
|
||||
uuid::Uuid::parse_str(engine.session_id()).is_ok(),
|
||||
"headless callers keep the generated uuid"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -291,33 +291,99 @@ async fn real_host_ocr_cancellation_bounds_parser_tree_and_never_replays() {
|
||||
drop(warm);
|
||||
let marker = root.path().join("launched");
|
||||
let child = root.path().join("descendant-survived");
|
||||
let parser_pid = root.path().join("parser.pid");
|
||||
let descendant_pid = root.path().join("descendant.pid");
|
||||
let ready = root.path().join("descendant-ready");
|
||||
let release = root.path().join("release-descendant");
|
||||
let tess = binary(
|
||||
root.path(),
|
||||
&format!(
|
||||
"printf x >> '{}'; (/bin/sleep 1; printf alive > '{}') &\n/bin/sleep 30",
|
||||
concat!(
|
||||
"printf '%s\\n' \"$$\" > '{}'\n",
|
||||
"printf x >> '{}'\n",
|
||||
"(printf ready > '{}'; while [ ! -e '{}' ]; do /bin/sleep 0.02; done; printf alive > '{}') &\n",
|
||||
"printf '%s\\n' \"$!\" > '{}'\nwait",
|
||||
),
|
||||
parser_pid.display(),
|
||||
marker.display(),
|
||||
child.display()
|
||||
ready.display(),
|
||||
release.display(),
|
||||
child.display(),
|
||||
descendant_pid.display(),
|
||||
),
|
||||
);
|
||||
let _override = overrides(Ok(None), Some(tess.into_os_string()));
|
||||
let cancel = CancellationToken::new();
|
||||
let stop = cancel.clone();
|
||||
let check = marker.clone();
|
||||
let waiter = tokio::spawn(async move {
|
||||
for _ in 0..1000 {
|
||||
if check.exists() {
|
||||
stop.cancel();
|
||||
return;
|
||||
// Backstop assertion/timeout failures without leaving a latched parser tree.
|
||||
struct ParserCleanup {
|
||||
cancel: CancellationToken,
|
||||
pid_file: PathBuf,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for ParserCleanup {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.cancel();
|
||||
if self.armed
|
||||
&& let Some(pid) = std::fs::read_to_string(&self.pid_file)
|
||||
.ok()
|
||||
.and_then(|text| text.trim().parse::<libc::pid_t>().ok())
|
||||
.filter(|pid| *pid > 0)
|
||||
{
|
||||
// SAFETY: this private fixture records the contained parser's
|
||||
// process-group leader; a negative PID targets only that group.
|
||||
unsafe { libc::kill(-pid, libc::SIGKILL) };
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
panic!("OCR parser never launched");
|
||||
}
|
||||
let mut cleanup = ParserCleanup {
|
||||
cancel: cancel.clone(),
|
||||
pid_file: parser_pid.clone(),
|
||||
armed: true,
|
||||
};
|
||||
let stop = cancel.clone();
|
||||
let waiter = tokio::spawn(async move {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !ready.exists() || !descendant_pid.exists() {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("OCR descendant never became ready");
|
||||
let pids = tokio::task::spawn_blocking(move || {
|
||||
(
|
||||
crate::process_tree::read_pid_file(&parser_pid, Duration::from_secs(5)),
|
||||
crate::process_tree::read_pid_file(&descendant_pid, Duration::from_secs(5)),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
stop.cancel();
|
||||
pids
|
||||
});
|
||||
let ctx = context(root.path(), true).with_cancel_token(cancel);
|
||||
let err = ocr_image_path(&image, &ctx).await.unwrap_err();
|
||||
let err = tokio::time::timeout(Duration::from_secs(20), ocr_image_path(&image, &ctx))
|
||||
.await
|
||||
.expect("OCR cancellation did not return")
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::Cancelled { .. }));
|
||||
waiter.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
let (parser, descendant) = waiter.await.unwrap();
|
||||
// Host cancellation returns before its retained worker has cleaned up.
|
||||
// Observe that cleanup while allowing the current-thread runtime to run it.
|
||||
let stopped = tokio::task::spawn_blocking(move || {
|
||||
let descendant_stopped =
|
||||
crate::process_tree::wait_for_pid_exit(descendant, Duration::from_secs(5));
|
||||
let parser_stopped = crate::process_tree::wait_for_pid_exit(parser, Duration::from_secs(5));
|
||||
descendant_stopped && parser_stopped
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
if stopped {
|
||||
cleanup.armed = false;
|
||||
}
|
||||
assert!(stopped, "cancelled OCR parser tree is still running");
|
||||
// A delayed cancellation observer can no longer race a one-second writer:
|
||||
// the descendant could not write before this explicit release.
|
||||
std::fs::write(release, b"release").unwrap();
|
||||
assert_eq!(std::fs::read(marker).unwrap(), b"x");
|
||||
assert!(!child.exists());
|
||||
manager.shutdown().await;
|
||||
|
||||
@@ -1738,8 +1738,8 @@ pub(crate) async fn drive_child_actor(
|
||||
handle.cancel();
|
||||
} else {
|
||||
match decision {
|
||||
Ok(ChildApprovalOutcome::Approved) => handle.approve_tool_call(id).await?,
|
||||
Ok(ChildApprovalOutcome::Denied) => handle.deny_tool_call(id).await?,
|
||||
Ok(ChildApprovalOutcome::Approved { by }) => handle.approve_tool_call_by(id, by).await?,
|
||||
Ok(ChildApprovalOutcome::Denied { by }) => handle.deny_tool_call_by(id, by).await?,
|
||||
_ => handle.deny_tool_call_unavailable(id).await?,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3948,11 +3948,15 @@ pub(crate) fn child_approval_keys(agent_id: &str, name: &str, input: &Value) ->
|
||||
)
|
||||
}
|
||||
|
||||
/// A person's answer to an approval prompt raised for a child's tool call.
|
||||
/// An answer to a child's approval prompt, preserving who made the decision.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChildApprovalOutcome {
|
||||
Approved,
|
||||
Denied,
|
||||
Approved {
|
||||
by: crate::approval_log::ApprovalDecider,
|
||||
},
|
||||
Denied {
|
||||
by: crate::approval_log::ApprovalDecider,
|
||||
},
|
||||
/// The host could not put the request in front of a person (for example
|
||||
/// it belongs to a conversation that is no longer shown). Recorded as
|
||||
/// `unavailable`, never as the person's denial.
|
||||
|
||||
@@ -323,23 +323,42 @@ async fn child_execution_identity_cannot_replace_a_waiter_or_answer_a_later_call
|
||||
.register_child_approval("other", &first, "bash", "wrong owner")
|
||||
.is_err()
|
||||
);
|
||||
assert!(manager.resolve_child_approval(&first, ChildApprovalOutcome::Denied));
|
||||
assert!(manager.resolve_child_approval(
|
||||
&first,
|
||||
ChildApprovalOutcome::Denied {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
receiver.await.unwrap(),
|
||||
ChildApprovalOutcome::Denied
|
||||
ChildApprovalOutcome::Denied {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
let (_, mut receiver) = manager
|
||||
.register_child_approval("child", &second, "bash", "two")
|
||||
.unwrap();
|
||||
assert!(!manager.resolve_child_approval(&first, ChildApprovalOutcome::Approved));
|
||||
assert!(!manager.resolve_child_approval(
|
||||
&first,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
|
||||
));
|
||||
assert!(manager.resolve_child_approval(&second, ChildApprovalOutcome::Approved));
|
||||
assert!(manager.resolve_child_approval(
|
||||
&second,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
receiver.await.unwrap(),
|
||||
ChildApprovalOutcome::Approved
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
@@ -20091,7 +20110,12 @@ async fn status_waiting_is_derived_from_pending_store() {
|
||||
None,
|
||||
Some("bash".to_string()),
|
||||
);
|
||||
assert!(inner.resolve_child_approval(&approval_id, ChildApprovalOutcome::Approved));
|
||||
assert!(inner.resolve_child_approval(
|
||||
&approval_id,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
let record = inner.get_worker_record(&agent_id).expect("worker record");
|
||||
assert_ne!(record.status, AgentWorkerStatus::WaitingForUser);
|
||||
assert!(record.pending_request.is_none());
|
||||
@@ -24379,10 +24403,21 @@ mod child_permission_gate {
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_with_a_prompting_host_raises_the_prompt_and_honours_the_answer() {
|
||||
for (answer, expect_ok) in [
|
||||
(ChildApprovalOutcome::Approved, true),
|
||||
(ChildApprovalOutcome::Denied, false),
|
||||
] {
|
||||
use crate::approval_log::ApprovalDecider;
|
||||
for (by, expect_ok) in [
|
||||
ApprovalDecider::User,
|
||||
ApprovalDecider::SessionRule,
|
||||
ApprovalDecider::Posture,
|
||||
ApprovalDecider::Host,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|by| [(by, true), (by, false)])
|
||||
{
|
||||
let answer = if expect_ok {
|
||||
ChildApprovalOutcome::Approved { by }
|
||||
} else {
|
||||
ChildApprovalOutcome::Denied { by }
|
||||
};
|
||||
let (registry, mut rx, manager) =
|
||||
worker_registry(ApprovalMode::Suggest, false, true, None);
|
||||
let (receipt_store, session_id) = receipt_context(®istry);
|
||||
@@ -24465,10 +24500,18 @@ mod child_permission_gate {
|
||||
(true, Ok(output)) => assert!(output.contains("gated"), "{output}"),
|
||||
(false, Err(err)) => {
|
||||
assert!(
|
||||
matches!(err.downcast_ref::<ToolError>(), Some(ToolError::PermissionDenied { message })
|
||||
if message.starts_with("Tool 'bash' denied by user — ")),
|
||||
matches!(
|
||||
err.downcast_ref::<ToolError>(),
|
||||
Some(ToolError::PermissionDenied { .. })
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
if by == ApprovalDecider::User {
|
||||
assert!(
|
||||
err.to_string().starts_with("Tool 'bash' denied by user — "),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
(true, Err(err)) => panic!("approved call must run: {err}"),
|
||||
(false, Ok(output)) => panic!("denied call must not run: {output}"),
|
||||
@@ -24484,6 +24527,7 @@ mod child_permission_gate {
|
||||
ApprovalOutcome::Denied
|
||||
};
|
||||
assert_eq!(replay.completed[0].outcome, expected_outcome);
|
||||
assert_eq!(replay.completed[0].decided_by, Some(by));
|
||||
assert!(matches!(
|
||||
&replay.completed[0].ask,
|
||||
ApprovalReceipt::Asked { tool_name, .. } if tool_name == "bash"
|
||||
@@ -24542,10 +24586,12 @@ mod child_permission_gate {
|
||||
..
|
||||
} = event
|
||||
{
|
||||
manager_for_answer
|
||||
.write()
|
||||
.await
|
||||
.resolve_child_approval(&id, ChildApprovalOutcome::Denied);
|
||||
manager_for_answer.write().await.resolve_child_approval(
|
||||
&id,
|
||||
ChildApprovalOutcome::Denied {
|
||||
by: crate::approval_log::ApprovalDecider::User,
|
||||
},
|
||||
);
|
||||
return (approval_key, approval_grouping_key);
|
||||
}
|
||||
}
|
||||
@@ -24671,7 +24717,12 @@ mod child_permission_gate {
|
||||
.task_handle
|
||||
.is_some()
|
||||
);
|
||||
assert!(!manager.resolve_child_approval(&approval_id, ChildApprovalOutcome::Approved));
|
||||
assert!(!manager.resolve_child_approval(
|
||||
&approval_id,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
}
|
||||
let requested = manager.read().await.get_result(&agent_id).expect("agent");
|
||||
let settled = settle_requested_child(&manager, requested).await;
|
||||
@@ -24695,12 +24746,12 @@ mod child_permission_gate {
|
||||
);
|
||||
assert_completed_receipt(&receipt_store, &session_id, ApprovalOutcome::Cancelled);
|
||||
// A late answer finds nobody waiting and applies to nothing.
|
||||
assert!(
|
||||
!manager
|
||||
.write()
|
||||
.await
|
||||
.resolve_child_approval(&approval_id, ChildApprovalOutcome::Approved)
|
||||
);
|
||||
assert!(!manager.write().await.resolve_child_approval(
|
||||
&approval_id,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -24756,12 +24807,12 @@ mod child_permission_gate {
|
||||
let id = next_child_approval_id(&mut rx).await;
|
||||
// The person takes several tool timeouts to decide.
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
assert!(
|
||||
manager_for_answer
|
||||
.write()
|
||||
.await
|
||||
.resolve_child_approval(&id, ChildApprovalOutcome::Approved)
|
||||
);
|
||||
assert!(manager_for_answer.write().await.resolve_child_approval(
|
||||
&id,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
});
|
||||
let output = run_tool_with_person_aware_timeout(
|
||||
tool_timeout,
|
||||
@@ -24907,12 +24958,12 @@ mod child_permission_gate {
|
||||
let approval_id = next_child_approval_id(&mut rx).await;
|
||||
std::fs::remove_file(&log_path).expect("remove log after durable ask");
|
||||
std::fs::create_dir(&log_path).expect("replace log with unwritable directory");
|
||||
assert!(
|
||||
manager_for_answer
|
||||
.write()
|
||||
.await
|
||||
.resolve_child_approval(&approval_id, ChildApprovalOutcome::Approved)
|
||||
);
|
||||
assert!(manager_for_answer.write().await.resolve_child_approval(
|
||||
&approval_id,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
});
|
||||
|
||||
let err = registry
|
||||
@@ -25295,9 +25346,19 @@ mod child_permission_gate {
|
||||
));
|
||||
let mut manager = manager.write().await;
|
||||
assert_eq!(manager.pending_child_approvals(), 1);
|
||||
assert!(manager.resolve_child_approval(&id, ChildApprovalOutcome::Denied));
|
||||
assert!(manager.resolve_child_approval(
|
||||
&id,
|
||||
ChildApprovalOutcome::Denied {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
!manager.resolve_child_approval(&id, ChildApprovalOutcome::Approved),
|
||||
!manager.resolve_child_approval(
|
||||
&id,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
),
|
||||
"a late allow cannot replace the same denied request"
|
||||
);
|
||||
id
|
||||
@@ -25521,12 +25582,12 @@ mod child_permission_gate {
|
||||
};
|
||||
// The person tightens Permissions, then answers the open card.
|
||||
live.switch_for_tests(ApprovalMode::Never);
|
||||
assert!(
|
||||
manager
|
||||
.write()
|
||||
.await
|
||||
.resolve_child_approval(&id, ChildApprovalOutcome::Approved)
|
||||
);
|
||||
assert!(manager.write().await.resolve_child_approval(
|
||||
&id,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
));
|
||||
id
|
||||
};
|
||||
let (result, id) = tokio::join!(call, answer);
|
||||
@@ -25542,10 +25603,12 @@ mod child_permission_gate {
|
||||
);
|
||||
assert_completed_receipt(&receipt_store, &session_id, ApprovalOutcome::ApprovedOnce);
|
||||
assert!(
|
||||
!manager
|
||||
.write()
|
||||
.await
|
||||
.resolve_child_approval(&id, ChildApprovalOutcome::Approved),
|
||||
!manager.write().await.resolve_child_approval(
|
||||
&id,
|
||||
ChildApprovalOutcome::Approved {
|
||||
by: crate::approval_log::ApprovalDecider::User
|
||||
}
|
||||
),
|
||||
"a late allow cannot revive the already refused call"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1594,6 +1594,8 @@ pub struct App {
|
||||
/// Receipts are aligned to transcript cells because provider context can
|
||||
/// be compacted or purged without changing what remains visible.
|
||||
completed_assistant_outputs: Vec<CompletedAssistantOutputReceipt>,
|
||||
/// Exact completed Plan text awaiting this session/turn's host answer.
|
||||
pub(crate) pending_plan_handoff: Option<crate::tui::plan_handoff::PendingPlanHandoff>,
|
||||
pub(crate) context_token_cache: RefCell<ContextTokenCache>,
|
||||
/// Typed account-owned browser relay for this exact TUI session.
|
||||
pub remote_control: crate::remote_control::RemoteControlController,
|
||||
@@ -5014,6 +5016,7 @@ impl App {
|
||||
self.history.clear();
|
||||
self.history_revisions.clear();
|
||||
self.completed_assistant_outputs.clear();
|
||||
self.pending_plan_handoff = None;
|
||||
self.context_references_by_cell.clear();
|
||||
self.session_context_references.clear();
|
||||
self.session_artifacts.clear();
|
||||
@@ -5073,6 +5076,85 @@ impl App {
|
||||
.map(|receipt| receipt.text.as_str())
|
||||
}
|
||||
|
||||
/// Freeze only output completed inside this turn's transcript boundary.
|
||||
/// Restored/older receipts cannot cause a new hand-off.
|
||||
pub(crate) fn prepare_plan_handoff(
|
||||
&mut self,
|
||||
status: crate::core::events::TurnOutcomeStatus,
|
||||
turn_id: Option<&str>,
|
||||
has_open_todos: bool,
|
||||
) -> Option<String> {
|
||||
let receipt = self
|
||||
.completed_assistant_outputs
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|receipt| {
|
||||
receipt.history_index >= self.ocean_turn_history_start
|
||||
&& receipt.history_index < self.history.len()
|
||||
});
|
||||
if !crate::tui::plan_handoff::plan_ready(
|
||||
self.mode,
|
||||
status,
|
||||
receipt.map(|receipt| receipt.text.as_str()),
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let session_id = self.current_session_id.as_ref()?;
|
||||
let turn_id = turn_id.filter(|id| self.runtime_turn_id.as_deref() == Some(*id))?;
|
||||
if self.pending_plan_handoff.is_some() {
|
||||
return None;
|
||||
}
|
||||
let request_id = format!(
|
||||
"{}:{}:{}:{}",
|
||||
crate::tui::plan_handoff::REQUEST_ID,
|
||||
session_id.len(),
|
||||
session_id,
|
||||
turn_id
|
||||
);
|
||||
self.pending_plan_handoff = Some(crate::tui::plan_handoff::PendingPlanHandoff {
|
||||
request_id: request_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
turn_id: turn_id.to_string(),
|
||||
history_start: self.ocean_turn_history_start,
|
||||
transcript_epoch: self.transcript_identity_epoch,
|
||||
text: receipt?.text.clone(),
|
||||
// Only successful typed receipts inside this turn can preserve a
|
||||
// checklist. Tool evidence can outlive UI-initiated dispatch.
|
||||
has_current_checklist: has_open_todos
|
||||
&& self.history.get(self.ocean_turn_history_start..).is_some_and(|cells| {
|
||||
cells.iter().any(|cell| {
|
||||
matches!(cell, HistoryCell::Tool(crate::tui::history::ToolCell::Generic(tool))
|
||||
if tool.status == crate::tui::history::ToolStatus::Success
|
||||
&& crate::tui::history::is_checklist_tool_name(&tool.name))
|
||||
})
|
||||
}),
|
||||
seeded_todo_id: None,
|
||||
});
|
||||
Some(request_id)
|
||||
}
|
||||
|
||||
pub(crate) fn plan_handoff_is_current(
|
||||
&self,
|
||||
plan: &crate::tui::plan_handoff::PendingPlanHandoff,
|
||||
request_id: &str,
|
||||
) -> bool {
|
||||
plan.request_id == request_id
|
||||
&& self.current_session_id.as_deref() == Some(plan.session_id.as_str())
|
||||
&& self.runtime_turn_id.as_deref() == Some(plan.turn_id.as_str())
|
||||
&& self.ocean_turn_history_start == plan.history_start
|
||||
&& self.transcript_identity_epoch == plan.transcript_epoch
|
||||
&& self.mode == AppMode::Plan
|
||||
&& !self.is_loading
|
||||
&& !self.dispatch_in_flight
|
||||
&& self.pending_steers.is_empty()
|
||||
&& !self.remote_control.runtime_chat_blocks_local_dispatch()
|
||||
&& self.queued_message_count() == 0
|
||||
&& self.queued_draft.is_none()
|
||||
&& self.input.is_empty()
|
||||
&& self.view_stack.is_empty()
|
||||
&& self.agent_focus.is_none()
|
||||
}
|
||||
|
||||
/// Pop the trailing history cell, keeping revisions in sync.
|
||||
pub fn pop_history(&mut self) -> Option<HistoryCell> {
|
||||
let cell = self.history.pop();
|
||||
|
||||
@@ -806,6 +806,7 @@ impl App {
|
||||
api_message_stamps: Vec::new(),
|
||||
session_journal: crate::session_tree::SessionJournal::new(),
|
||||
completed_assistant_outputs: Vec::new(),
|
||||
pending_plan_handoff: None,
|
||||
context_token_cache: std::cell::RefCell::new(Default::default()),
|
||||
remote_control: crate::remote_control::RemoteControlController::default(),
|
||||
start_remote_control_on_launch: start_remote_control,
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
//! Plan hand-off: when a Plan turn leaves open To-do steps, ask how to go on.
|
||||
//! Plan hand-off: ask how to continue a successfully completed Plan response.
|
||||
//!
|
||||
//! The exact completed text is frozen for one session and turn. A prose plan
|
||||
//! seeds one bounded pending item; it is not parsed into English-shaped steps.
|
||||
//! Checkpoint admission uses the incumbent queued boundary, not a disk-write
|
||||
//! acknowledgement. Failed preparation removes only its unchanged seed from
|
||||
//! the current To-do projection; the graph retains unprojected history. That
|
||||
//! repair cannot recall a checkpoint already accepted by the persistence actor.
|
||||
//! If cleanup refuses because the owned item changed or Work validation fails,
|
||||
//! retain its ownership and refuse a new dispatch rather than erase other work.
|
||||
//!
|
||||
//! The question uses the modal `request_user_input` already draws. It is
|
||||
//! host-owned: no tool call is waiting in the engine, so the answer is applied
|
||||
@@ -10,12 +19,9 @@ use codewhale_execpolicy::ApprovalMode;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
|
||||
use crate::core::events::TurnOutcomeStatus;
|
||||
use crate::tools::todo::TodoListSnapshot;
|
||||
use crate::tools::user_input::{
|
||||
UserInputOption, UserInputQuestion, UserInputRequest, UserInputResponse,
|
||||
};
|
||||
use crate::tui::app::ToolEvidence;
|
||||
use crate::tui::history::is_checklist_tool_name;
|
||||
|
||||
/// Request id of the hand-off question on the view stack.
|
||||
pub(crate) const REQUEST_ID: &str = "codewhale:plan-handoff";
|
||||
@@ -32,20 +38,26 @@ pub(crate) enum PlanHandoffChoice {
|
||||
KeepPlanning,
|
||||
}
|
||||
|
||||
/// Whether a finished turn left a plan to hand off: it ran in Plan, completed,
|
||||
/// wrote the To-do list, and that list still has open steps.
|
||||
pub(crate) fn plan_ready(
|
||||
mode: AppMode,
|
||||
status: TurnOutcomeStatus,
|
||||
evidence: &[ToolEvidence],
|
||||
todos: &TodoListSnapshot,
|
||||
) -> bool {
|
||||
/// One completed Plan response. The question id prevents a delayed answer
|
||||
/// from approving a newer plan, even in the same session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingPlanHandoff {
|
||||
pub request_id: String,
|
||||
pub session_id: String,
|
||||
pub turn_id: String,
|
||||
pub history_start: usize,
|
||||
pub transcript_epoch: u64,
|
||||
pub text: String,
|
||||
pub has_current_checklist: bool,
|
||||
/// Retained only if failed-preparation cleanup cannot safely remove it.
|
||||
pub seeded_todo_id: Option<u32>,
|
||||
}
|
||||
|
||||
/// Only typed, successful, nonempty output from the current Plan turn qualifies.
|
||||
pub(crate) fn plan_ready(mode: AppMode, status: TurnOutcomeStatus, output: Option<&str>) -> bool {
|
||||
mode == AppMode::Plan
|
||||
&& status == TurnOutcomeStatus::Completed
|
||||
&& evidence
|
||||
.iter()
|
||||
.any(|entry| is_checklist_tool_name(&entry.tool_name))
|
||||
&& todos.items.iter().any(|item| !item.status.is_settled())
|
||||
&& output.is_some_and(|text| !text.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The hand-off question, in the person's language.
|
||||
@@ -106,31 +118,8 @@ pub(crate) fn choice(locale: Locale, response: &UserInputResponse) -> PlanHandof
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::todo::{TodoItem, TodoStatus};
|
||||
use crate::tools::user_input::UserInputAnswer;
|
||||
|
||||
fn evidence(tool_name: &str) -> Vec<ToolEvidence> {
|
||||
vec![ToolEvidence {
|
||||
tool_name: tool_name.to_string(),
|
||||
summary: String::new(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn todos(statuses: &[TodoStatus]) -> TodoListSnapshot {
|
||||
TodoListSnapshot {
|
||||
items: statuses
|
||||
.iter()
|
||||
.zip(1u32..)
|
||||
.map(|(status, id)| TodoItem {
|
||||
id,
|
||||
content: format!("step {id}"),
|
||||
status: *status,
|
||||
})
|
||||
.collect(),
|
||||
..TodoListSnapshot::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(label: &str, value: &str) -> UserInputResponse {
|
||||
UserInputResponse {
|
||||
answers: vec![UserInputAnswer {
|
||||
@@ -142,56 +131,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completed_plan_turn_with_open_todo_steps_is_ready() {
|
||||
let open = todos(&[TodoStatus::Completed, TodoStatus::Pending]);
|
||||
let wrote = evidence("todo_write");
|
||||
fn only_successful_nonempty_plan_output_is_ready() {
|
||||
let prose = Some("先检查数据。\n\nThen build the report.");
|
||||
assert!(plan_ready(
|
||||
AppMode::Plan,
|
||||
TurnOutcomeStatus::Completed,
|
||||
&wrote,
|
||||
&open
|
||||
prose
|
||||
));
|
||||
|
||||
// Work and Operate turns write the same list while doing the work.
|
||||
for mode in [AppMode::Agent, AppMode::Operate] {
|
||||
assert!(!plan_ready(mode, TurnOutcomeStatus::Completed, prose));
|
||||
}
|
||||
for status in [TurnOutcomeStatus::Interrupted, TurnOutcomeStatus::Failed] {
|
||||
assert!(!plan_ready(AppMode::Plan, status, prose));
|
||||
}
|
||||
for output in [None, Some(""), Some(" \n ")] {
|
||||
assert!(!plan_ready(
|
||||
mode,
|
||||
AppMode::Plan,
|
||||
TurnOutcomeStatus::Completed,
|
||||
&wrote,
|
||||
&open
|
||||
output
|
||||
));
|
||||
}
|
||||
// An interrupted or failed turn did not finish its plan.
|
||||
for status in [TurnOutcomeStatus::Interrupted, TurnOutcomeStatus::Failed] {
|
||||
assert!(!plan_ready(AppMode::Plan, status, &wrote, &open));
|
||||
}
|
||||
// A Plan turn that only answered a question left no new plan, even
|
||||
// when an older list is still open.
|
||||
assert!(!plan_ready(
|
||||
AppMode::Plan,
|
||||
TurnOutcomeStatus::Completed,
|
||||
&evidence("read"),
|
||||
&open
|
||||
));
|
||||
assert!(!plan_ready(
|
||||
AppMode::Plan,
|
||||
TurnOutcomeStatus::Completed,
|
||||
&[],
|
||||
&open
|
||||
));
|
||||
// Nothing left to start.
|
||||
assert!(!plan_ready(
|
||||
AppMode::Plan,
|
||||
TurnOutcomeStatus::Completed,
|
||||
&wrote,
|
||||
&todos(&[TodoStatus::Completed, TodoStatus::Cancelled])
|
||||
));
|
||||
assert!(!plan_ready(
|
||||
AppMode::Plan,
|
||||
TurnOutcomeStatus::Completed,
|
||||
&wrote,
|
||||
&todos(&[])
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2843,6 +2843,7 @@ pub(crate) async fn run_event_loop(
|
||||
app.ocean_completion_started_at = None;
|
||||
app.ocean_receipt_settle_start = None;
|
||||
app.ocean_turn_history_start = app.history.len();
|
||||
app.pending_plan_handoff = None;
|
||||
app.suppress_stream_events_until_turn_complete = false;
|
||||
app.is_loading = true;
|
||||
app.offline_mode = false;
|
||||
@@ -3520,14 +3521,18 @@ pub(crate) async fn run_event_loop(
|
||||
});
|
||||
}
|
||||
|
||||
// Plan hand-off: a Plan turn that left open To-do
|
||||
// steps asks how to go on instead of just ending.
|
||||
// Plan hand-off freezes this successful turn's exact
|
||||
// completed response, including prose-only plans.
|
||||
// Only on an idle screen — a modal that takes digit
|
||||
// keys must never land on a draft, a queued follow-up
|
||||
// or another open view, and the answer goes to this
|
||||
// session, not to a focused agent.
|
||||
if queued_to_send.is_none()
|
||||
&& !newer_dispatch_owns_turn_state
|
||||
&& !app.is_loading
|
||||
&& !app.dispatch_in_flight
|
||||
&& app.pending_steers.is_empty()
|
||||
&& !app.remote_control.runtime_chat_blocks_local_dispatch()
|
||||
&& app.queued_message_count() == 0
|
||||
&& app.queued_draft.is_none()
|
||||
&& app.input.is_empty()
|
||||
@@ -3535,14 +3540,17 @@ pub(crate) async fn run_event_loop(
|
||||
&& app.agent_focus.is_none()
|
||||
{
|
||||
let todos = app.todos.lock().await.snapshot();
|
||||
if crate::tui::plan_handoff::plan_ready(
|
||||
app.mode,
|
||||
status,
|
||||
&app.tool_evidence,
|
||||
&todos,
|
||||
) {
|
||||
let has_open_todos =
|
||||
todos.items.iter().any(|item| !item.status.is_settled());
|
||||
if !was_locally_cancelled
|
||||
&& let Some(request_id) = app.prepare_plan_handoff(
|
||||
status,
|
||||
completed_turn.as_ref().map(|turn| turn.turn_id.as_str()),
|
||||
has_open_todos,
|
||||
)
|
||||
{
|
||||
app.view_stack.push(UserInputView::new(
|
||||
crate::tui::plan_handoff::REQUEST_ID,
|
||||
request_id,
|
||||
crate::tui::plan_handoff::request(app.ui_locale),
|
||||
));
|
||||
app.needs_redraw = true;
|
||||
|
||||
@@ -1486,34 +1486,202 @@ async fn handle_theme_selection_updated(
|
||||
/// `pending_user_input_prompt` before its view opens, so a provider-chosen
|
||||
/// tool-call id equal to the hand-off id still reaches the engine.
|
||||
pub(crate) fn is_plan_handoff_request(app: &App, tool_id: &str) -> bool {
|
||||
tool_id == crate::tui::plan_handoff::REQUEST_ID
|
||||
(tool_id == crate::tui::plan_handoff::REQUEST_ID
|
||||
|| tool_id.starts_with(&format!("{}:", crate::tui::plan_handoff::REQUEST_ID)))
|
||||
&& app
|
||||
.pending_user_input_prompt
|
||||
.as_ref()
|
||||
.is_none_or(|(id, _)| id != tool_id)
|
||||
}
|
||||
|
||||
/// Carry out the answer to the Plan hand-off question.
|
||||
fn plan_handoff_seed_title(plan: &crate::tui::plan_handoff::PendingPlanHandoff) -> String {
|
||||
// Match the graph's trimmed title without changing the approved message.
|
||||
plan.text
|
||||
.trim()
|
||||
.chars()
|
||||
.take(1024)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Stage one graph-owned item for a prose plan without replacing existing work.
|
||||
async fn seed_plan_handoff_todos(
|
||||
app: &App,
|
||||
plan: &mut crate::tui::plan_handoff::PendingPlanHandoff,
|
||||
) -> Result<(), String> {
|
||||
if plan.has_current_checklist {
|
||||
return Ok(());
|
||||
}
|
||||
let work = app
|
||||
.runtime_services
|
||||
.work
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Work state is unavailable".to_string())?;
|
||||
let mut todos = work.current_todos().await?;
|
||||
let content = plan_handoff_seed_title(plan);
|
||||
if let Some(id) = plan.seeded_todo_id {
|
||||
return if todos.items.iter().any(|item| {
|
||||
item.id == id
|
||||
&& item.content == content
|
||||
&& item.status == crate::tools::todo::TodoStatus::Pending
|
||||
}) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("The approved plan's To-do changed; review the plan again".to_string())
|
||||
};
|
||||
}
|
||||
let id = todos
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "To-do item IDs are exhausted".to_string())?;
|
||||
todos.items.push(crate::tools::todo::TodoItem {
|
||||
id,
|
||||
content,
|
||||
status: crate::tools::todo::TodoStatus::Pending,
|
||||
});
|
||||
work.apply_todo_update(&plan.session_id, "todo_write", &todos)
|
||||
.await?;
|
||||
plan.seeded_todo_id = Some(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove only this unchanged seed from the latest projection. Unprojected
|
||||
/// graph history remains; operation_parent cannot adopt an unprojected step.
|
||||
async fn rollback_plan_handoff_seed(
|
||||
app: &App,
|
||||
plan: &mut crate::tui::plan_handoff::PendingPlanHandoff,
|
||||
) -> Result<(), String> {
|
||||
let Some(id) = plan.seeded_todo_id else {
|
||||
return Ok(());
|
||||
};
|
||||
if app.current_session_id.as_deref() != Some(plan.session_id.as_str()) {
|
||||
return Err("The plan belongs to another session".to_string());
|
||||
}
|
||||
let work = app
|
||||
.runtime_services
|
||||
.work
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Work state is unavailable".to_string())?;
|
||||
let mut todos = work.current_todos().await?;
|
||||
if let Some(item) = todos.items.iter().find(|item| item.id == id) {
|
||||
if item.content != plan_handoff_seed_title(plan)
|
||||
|| item.status != crate::tools::todo::TodoStatus::Pending
|
||||
{
|
||||
return Err(
|
||||
"The approved plan's To-do changed; review it before continuing".to_string(),
|
||||
);
|
||||
}
|
||||
todos.items.retain(|item| item.id != id);
|
||||
work.apply_todo_update(&plan.session_id, "todo_write", &todos)
|
||||
.await?;
|
||||
}
|
||||
plan.seeded_todo_id = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reopen_plan_handoff(
|
||||
app: &mut App,
|
||||
plan: &crate::tui::plan_handoff::PendingPlanHandoff,
|
||||
reason: &str,
|
||||
) {
|
||||
app.view_stack.push(UserInputView::new(
|
||||
plan.request_id.clone(),
|
||||
crate::tui::plan_handoff::request(app.ui_locale),
|
||||
));
|
||||
let notice = app
|
||||
.tr(MessageId::SessionSaveFailed)
|
||||
.replace("{id}", &plan.session_id)
|
||||
.replace("{error}", reason);
|
||||
app.push_status_toast(notice, StatusToastLevel::Warning, Some(8_000));
|
||||
}
|
||||
|
||||
/// Carry out only the answer bound to this exact completed Plan response.
|
||||
pub(crate) async fn apply_plan_handoff(
|
||||
app: &mut App,
|
||||
config: &Config,
|
||||
engine_handle: &EngineHandle,
|
||||
request_id: &str,
|
||||
choice: crate::tui::plan_handoff::PlanHandoffChoice,
|
||||
) -> Result<()> {
|
||||
apply_plan_handoff_with_checkpoint(app, config, engine_handle, request_id, choice, |app| {
|
||||
Box::pin(persist_pending_work_checkpoint(app))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
type PlanHandoffCheckpoint =
|
||||
for<'a> fn(&'a mut App) -> Pin<Box<dyn Future<Output = Result<bool, String>> + 'a>>;
|
||||
|
||||
/// The checkpoint callback keeps the existing admission boundary explicit;
|
||||
/// tests can refuse admission without mutating the global persistence actor.
|
||||
pub(crate) async fn apply_plan_handoff_with_checkpoint(
|
||||
app: &mut App,
|
||||
config: &Config,
|
||||
engine_handle: &EngineHandle,
|
||||
request_id: &str,
|
||||
choice: crate::tui::plan_handoff::PlanHandoffChoice,
|
||||
checkpoint: PlanHandoffCheckpoint,
|
||||
) -> Result<()> {
|
||||
use crate::tui::plan_handoff::PlanHandoffChoice;
|
||||
|
||||
let Some(mut plan) = app
|
||||
.pending_plan_handoff
|
||||
.clone()
|
||||
.filter(|plan| app.plan_handoff_is_current(plan, request_id))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if !matches!(&choice, PlanHandoffChoice::Work(_))
|
||||
&& let Err(reason) = rollback_plan_handoff_seed(app, &mut plan).await
|
||||
{
|
||||
app.pending_plan_handoff = Some(plan.clone());
|
||||
reopen_plan_handoff(app, &plan, &reason);
|
||||
return Ok(());
|
||||
}
|
||||
let message = match choice {
|
||||
PlanHandoffChoice::KeepPlanning => return Ok(()),
|
||||
PlanHandoffChoice::KeepPlanning => {
|
||||
app.pending_plan_handoff = None;
|
||||
return Ok(());
|
||||
}
|
||||
PlanHandoffChoice::Revise(feedback) => QueuedMessage::new(feedback, None),
|
||||
PlanHandoffChoice::Work(posture) => {
|
||||
if !enter_work_for_plan(app, config, engine_handle, posture).await {
|
||||
return Ok(());
|
||||
}
|
||||
QueuedMessage::new(app.tr(MessageId::PlanHandoffProceed).into_owned(), None)
|
||||
let staged = seed_plan_handoff_todos(app, &mut plan).await;
|
||||
app.pending_plan_handoff = Some(plan.clone());
|
||||
let checkpoint = match staged {
|
||||
Ok(()) => checkpoint(app).await.map(|_| ()),
|
||||
Err(reason) => Err(reason),
|
||||
};
|
||||
if let Err(reason) = checkpoint {
|
||||
// Undo our unpublished projection before the event loop can
|
||||
// retry an ordinary checkpoint. Never roll back other work.
|
||||
// An already queued write cannot be recalled by this repair.
|
||||
let reason = match rollback_plan_handoff_seed(app, &mut plan).await {
|
||||
Ok(()) => reason,
|
||||
Err(cleanup) => format!("{reason}; {cleanup}"),
|
||||
};
|
||||
app.pending_plan_handoff = Some(plan.clone());
|
||||
apply_mode_update(app, engine_handle, config, AppMode::Plan).await;
|
||||
reopen_plan_handoff(app, &plan, &reason);
|
||||
return Ok(());
|
||||
}
|
||||
QueuedMessage::new(
|
||||
format!("{}\n\n{}", app.tr(MessageId::PlanHandoffProceed), plan.text),
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
// Sent the way a composer submit is: offline or mid-dispatch it queues,
|
||||
// and a failed send puts the text back instead of ending the session.
|
||||
// Consume before ordinary composer admission. Its existing recovery owns
|
||||
// the exact user message on an offline/failed send; a repeated answer
|
||||
// cannot enqueue another execution.
|
||||
app.pending_plan_handoff = None;
|
||||
let action = ComposerSubmitAction::Submit(app.decide_submit_disposition());
|
||||
dispatch_composer_message(
|
||||
app,
|
||||
@@ -1535,6 +1703,9 @@ pub(crate) async fn enter_work_for_plan(
|
||||
engine_handle: &EngineHandle,
|
||||
posture: ApprovalMode,
|
||||
) -> bool {
|
||||
if engine_handle.tx_op.is_closed() {
|
||||
return false;
|
||||
}
|
||||
if app.agent_approval_baseline() != posture
|
||||
&& let Err(reason) = app.apply_agent_posture(posture)
|
||||
{
|
||||
@@ -1542,6 +1713,10 @@ pub(crate) async fn enter_work_for_plan(
|
||||
return false;
|
||||
}
|
||||
apply_mode_update(app, engine_handle, config, AppMode::Agent).await;
|
||||
if engine_handle.tx_op.is_closed() {
|
||||
app.set_mode(AppMode::Plan);
|
||||
return false;
|
||||
}
|
||||
app.mode == AppMode::Agent
|
||||
}
|
||||
|
||||
@@ -1727,10 +1902,18 @@ pub(crate) async fn handle_view_events(
|
||||
if is_plan_handoff_request(app, &tool_id) =>
|
||||
{
|
||||
let choice = crate::tui::plan_handoff::choice(app.ui_locale, &response);
|
||||
apply_plan_handoff(app, config, engine_handle, choice).await?;
|
||||
apply_plan_handoff(app, config, engine_handle, &tool_id, choice).await?;
|
||||
}
|
||||
// Esc keeps planning: nothing was pending in the engine.
|
||||
// Esc follows the same owned-seed cleanup as Keep planning.
|
||||
ViewEvent::UserInputCancelled { tool_id } if is_plan_handoff_request(app, &tool_id) => {
|
||||
apply_plan_handoff(
|
||||
app,
|
||||
config,
|
||||
engine_handle,
|
||||
&tool_id,
|
||||
crate::tui::plan_handoff::PlanHandoffChoice::KeepPlanning,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ViewEvent::UserInputSubmitted { tool_id, response } => {
|
||||
let result = engine_handle
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
use super::*;
|
||||
use crate::tui::plan_handoff::{self, PlanHandoffChoice};
|
||||
use crate::tui::ui::handlers::{apply_plan_handoff, enter_work_for_plan, is_plan_handoff_request};
|
||||
use crate::tui::ui::handlers::{
|
||||
apply_plan_handoff, apply_plan_handoff_with_checkpoint, enter_work_for_plan,
|
||||
is_plan_handoff_request,
|
||||
};
|
||||
|
||||
/// A session sitting in Plan whose Work permission is Ask.
|
||||
fn app_in_plan() -> App {
|
||||
@@ -74,6 +77,7 @@ async fn a_locked_permission_keeps_the_session_in_plan() {
|
||||
#[tokio::test]
|
||||
async fn keep_planning_changes_nothing_and_sends_nothing() {
|
||||
let mut app = app_in_plan();
|
||||
let request_id = completed_plan(&mut app, "Inspect first, then make the change.", false);
|
||||
let history_len = app.history.len();
|
||||
let mut engine = mock_engine_handle();
|
||||
|
||||
@@ -81,6 +85,7 @@ async fn keep_planning_changes_nothing_and_sends_nothing() {
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::KeepPlanning,
|
||||
)
|
||||
.await
|
||||
@@ -89,6 +94,8 @@ async fn keep_planning_changes_nothing_and_sends_nothing() {
|
||||
assert_eq!(app.mode, AppMode::Plan);
|
||||
assert_eq!(app.history.len(), history_len);
|
||||
assert!(!app.is_loading);
|
||||
assert!(app.pending_plan_handoff.is_none());
|
||||
assert!(app.todos.lock().await.snapshot().is_empty());
|
||||
assert!(engine.rx_op.try_recv().is_err());
|
||||
}
|
||||
|
||||
@@ -106,3 +113,640 @@ fn an_engine_question_with_the_handoff_id_still_goes_to_the_engine() {
|
||||
));
|
||||
assert!(!is_plan_handoff_request(&app, plan_handoff::REQUEST_ID));
|
||||
}
|
||||
|
||||
/// Complete actual typed output inside the current turn; no tool event is needed.
|
||||
fn completed_plan(app: &mut App, text: &str, checklist: bool) -> String {
|
||||
app.current_session_id = Some("plan-session".into());
|
||||
app.runtime_turn_id = Some("plan-turn".into());
|
||||
app.ocean_turn_history_start = app.history.len();
|
||||
if checklist {
|
||||
push_checklist_receipt(app);
|
||||
}
|
||||
let index = app.history.len();
|
||||
app.push_history_cell(HistoryCell::Assistant {
|
||||
content: text.into(),
|
||||
streaming: false,
|
||||
});
|
||||
app.record_completed_assistant_output(index, text);
|
||||
let has_open_todos = app
|
||||
.todos
|
||||
.try_lock()
|
||||
.expect("test To-dos available")
|
||||
.snapshot()
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| !item.status.is_settled());
|
||||
app.prepare_plan_handoff(
|
||||
crate::core::events::TurnOutcomeStatus::Completed,
|
||||
Some("plan-turn"),
|
||||
has_open_todos,
|
||||
)
|
||||
.expect("completed current-turn plan")
|
||||
}
|
||||
|
||||
fn push_checklist_receipt(app: &mut App) {
|
||||
app.push_history_cell(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: "todo_write".into(),
|
||||
status: ToolStatus::Success,
|
||||
input_summary: None,
|
||||
output: None,
|
||||
prompts: None,
|
||||
spillover_path: None,
|
||||
output_summary: None,
|
||||
is_diff: false,
|
||||
})));
|
||||
}
|
||||
|
||||
// Exercise the real graph publication boundary without owning the singleton actor.
|
||||
fn accept_checkpoint(
|
||||
app: &mut App,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool, String>> + '_>> {
|
||||
Box::pin(publish_pending_work_projection(app))
|
||||
}
|
||||
|
||||
fn reject_checkpoint(
|
||||
_: &mut App,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool, String>> + '_>> {
|
||||
Box::pin(async { Err("test checkpoint admission refusal".to_string()) })
|
||||
}
|
||||
|
||||
fn reject_checkpoint_after_other_work_changes(
|
||||
app: &mut App,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool, String>> + '_>> {
|
||||
Box::pin(async move {
|
||||
let work = app.runtime_services.work.as_ref().unwrap();
|
||||
let mut todos = work.current_todos().await?;
|
||||
todos.items[0].content = "Updated unrelated work".into();
|
||||
todos.items[0].status = crate::tools::todo::TodoStatus::InProgress;
|
||||
let id = todos.items.iter().map(|item| item.id).max().unwrap() + 1;
|
||||
todos.items.push(crate::tools::todo::TodoItem {
|
||||
id,
|
||||
content: "Added while preparing work".into(),
|
||||
status: crate::tools::todo::TodoStatus::Pending,
|
||||
});
|
||||
work.apply_todo_update("plan-session", "todo_write", &todos)
|
||||
.await?;
|
||||
Err("test checkpoint admission refusal".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn reject_checkpoint_after_owned_seed_status_changes(
|
||||
app: &mut App,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool, String>> + '_>> {
|
||||
Box::pin(async move {
|
||||
let work = app.runtime_services.work.as_ref().unwrap();
|
||||
let mut todos = work.current_todos().await?;
|
||||
todos.items.last_mut().unwrap().status = crate::tools::todo::TodoStatus::InProgress;
|
||||
work.apply_todo_update("plan-session", "todo_write", &todos)
|
||||
.await?;
|
||||
Err("test checkpoint admission refusal".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_plan_receipt_excludes_old_empty_failed_and_interrupted_output() {
|
||||
let mut app = app_in_plan();
|
||||
completed_plan(&mut app, "An older plan", false);
|
||||
app.pending_plan_handoff = None;
|
||||
app.ocean_turn_history_start = app.history.len();
|
||||
assert!(
|
||||
app.prepare_plan_handoff(
|
||||
crate::core::events::TurnOutcomeStatus::Completed,
|
||||
Some("plan-turn"),
|
||||
false,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
for status in [
|
||||
crate::core::events::TurnOutcomeStatus::Interrupted,
|
||||
crate::core::events::TurnOutcomeStatus::Failed,
|
||||
] {
|
||||
app.ocean_turn_history_start = 0;
|
||||
assert!(
|
||||
app.prepare_plan_handoff(status, Some("plan-turn"), false)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
app.ocean_turn_history_start = app.history.len();
|
||||
let index = app.history.len();
|
||||
app.push_history_cell(HistoryCell::Assistant {
|
||||
content: " \n".into(),
|
||||
streaming: false,
|
||||
});
|
||||
app.record_completed_assistant_output(index, " \n");
|
||||
assert!(
|
||||
app.prepare_plan_handoff(
|
||||
crate::core::events::TurnOutcomeStatus::Completed,
|
||||
Some("plan-turn"),
|
||||
false,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prose_work_seeds_preserves_other_work_and_queues_exact_text_once() {
|
||||
use crate::tools::todo::TodoStatus;
|
||||
let mut app = app_in_plan();
|
||||
app.offline_mode = true; // ordinary composer queue; no provider admission.
|
||||
app.todos
|
||||
.lock()
|
||||
.await
|
||||
.add("Unrelated work".into(), TodoStatus::Pending);
|
||||
let text = format!(
|
||||
"先检查输入。\n\n{}\nThen implement and verify.\n",
|
||||
"long plan 🐳 ".repeat(150)
|
||||
);
|
||||
let request_id = completed_plan(&mut app, &text, false);
|
||||
let mut engine = mock_engine_handle();
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(ApprovalMode::Suggest),
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.expect("admitted Work handoff");
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
let todos = app.todos.lock().await.snapshot();
|
||||
assert_eq!(todos.items.len(), 2);
|
||||
assert_eq!(todos.items[0].content, "Unrelated work");
|
||||
assert_eq!(todos.items[1].status, TodoStatus::Pending);
|
||||
assert_eq!(
|
||||
todos.items[1].content,
|
||||
text.trim().chars().take(1024).collect::<String>().trim()
|
||||
);
|
||||
assert!(app.pending_plan_handoff.is_none());
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
assert_eq!(
|
||||
app.queued_messages.front().unwrap().display,
|
||||
format!("{}\n\n{text}", app.tr(MessageId::PlanHandoffProceed))
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.rx_op.try_recv(),
|
||||
Ok(crate::core::ops::Op::ChangeMode { .. })
|
||||
));
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(ApprovalMode::Suggest),
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
assert_eq!(app.todos.lock().await.snapshot(), todos);
|
||||
assert!(engine.rx_op.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_current_turn_checklist_is_not_replaced_or_seeded_again() {
|
||||
use crate::tools::todo::TodoStatus;
|
||||
let mut app = app_in_plan();
|
||||
app.offline_mode = true;
|
||||
app.todos
|
||||
.lock()
|
||||
.await
|
||||
.add("Existing execution step".into(), TodoStatus::Pending);
|
||||
let before = app.todos.lock().await.snapshot();
|
||||
let request_id = completed_plan(&mut app, "Execute the existing checklist.", true);
|
||||
let engine = mock_engine_handle();
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(ApprovalMode::Suggest),
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(app.todos.lock().await.snapshot(), before);
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_checkpoint_rolls_back_seed_and_retry_queues_once() {
|
||||
// Cover surrounding/multiline whitespace and a Unicode title whose
|
||||
// bounded prefix ends on whitespace before the graph normalizes it.
|
||||
for text in [
|
||||
" \n\tRead the data.\n\nBuild the report.\r\n ".to_string(),
|
||||
format!("\n{} \nKeep the full message.\t", "鲸".repeat(1023)),
|
||||
] {
|
||||
let mut app = app_in_plan();
|
||||
app.offline_mode = true;
|
||||
let request_id = completed_plan(&mut app, &text, false);
|
||||
let mut engine = mock_engine_handle();
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(ApprovalMode::Suggest),
|
||||
reject_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(app.mode, AppMode::Plan);
|
||||
assert_eq!(app.queued_message_count(), 0);
|
||||
assert!(
|
||||
app.todos.lock().await.snapshot().is_empty(),
|
||||
"unpublished seed must stay unpublished"
|
||||
);
|
||||
assert!(
|
||||
app.pending_plan_handoff
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.seeded_todo_id
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
app.runtime_services
|
||||
.work
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.current_todos()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(app.pending_plan_handoff.as_ref().unwrap().text, text);
|
||||
while app.view_stack.pop().is_some() {}
|
||||
while let Ok(op) = engine.rx_op.try_recv() {
|
||||
assert!(
|
||||
matches!(op, crate::core::ops::Op::ChangeMode { .. }),
|
||||
"no turn dispatch after refusal"
|
||||
);
|
||||
}
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(ApprovalMode::Suggest),
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let todos = app.todos.lock().await.snapshot();
|
||||
assert_eq!(todos.items.len(), 1);
|
||||
assert_eq!(
|
||||
todos.items[0].status,
|
||||
crate::tools::todo::TodoStatus::Pending
|
||||
);
|
||||
assert_eq!(
|
||||
todos.items[0].content,
|
||||
text.trim().chars().take(1024).collect::<String>().trim()
|
||||
);
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
assert_eq!(
|
||||
app.queued_messages.front().unwrap().display,
|
||||
format!("{}\n\n{text}", app.tr(MessageId::PlanHandoffProceed))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_preparation_can_keep_revise_or_escape_without_publishing_its_seed() {
|
||||
use crate::tools::todo::TodoStatus;
|
||||
for action in ["keep", "revise", "escape"] {
|
||||
let mut app = app_in_plan();
|
||||
app.offline_mode = true;
|
||||
app.todos
|
||||
.lock()
|
||||
.await
|
||||
.add("Unrelated work".into(), TodoStatus::Pending);
|
||||
let plan_text = "Build the report after approval.";
|
||||
let request_id = completed_plan(&mut app, plan_text, false);
|
||||
let mut engine = mock_engine_handle();
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(ApprovalMode::Suggest),
|
||||
reject_checkpoint_after_other_work_changes,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(app.mode, AppMode::Plan, "{action}");
|
||||
assert_eq!(app.queued_message_count(), 0, "{action}");
|
||||
assert!(
|
||||
app.pending_plan_handoff
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.seeded_todo_id
|
||||
.is_none()
|
||||
);
|
||||
let work = app.runtime_services.work.as_ref().unwrap().clone();
|
||||
let current = work.current_todos().await.unwrap();
|
||||
assert_eq!(current.items.len(), 2, "{action}");
|
||||
assert_eq!(
|
||||
current.items[0].content, "Updated unrelated work",
|
||||
"{action}"
|
||||
);
|
||||
assert_eq!(current.items[0].status, TodoStatus::InProgress, "{action}");
|
||||
assert_eq!(
|
||||
current.items[1].content, "Added while preparing work",
|
||||
"{action}"
|
||||
);
|
||||
assert_eq!(current.items[1].status, TodoStatus::Pending, "{action}");
|
||||
let snapshot = work.capture(Some("plan-session")).unwrap().unwrap();
|
||||
let seed = snapshot
|
||||
.graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.title == plan_text)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!snapshot.graph.compat.plan_order.contains(&seed.id),
|
||||
"{action}"
|
||||
);
|
||||
assert!(
|
||||
!snapshot
|
||||
.graph
|
||||
.compat
|
||||
.todos
|
||||
.iter()
|
||||
.any(|binding| binding.node == seed.id),
|
||||
"{action}"
|
||||
);
|
||||
// A background checkpoint while the retry question is still open
|
||||
// must publish only unrelated work, never the abandoned seed.
|
||||
publish_pending_work_projection(&mut app).await.unwrap();
|
||||
assert_eq!(app.todos.lock().await.snapshot(), current, "{action}");
|
||||
while let Ok(op) = engine.rx_op.try_recv() {
|
||||
assert!(
|
||||
matches!(op, crate::core::ops::Op::ChangeMode { .. }),
|
||||
"{action}"
|
||||
);
|
||||
}
|
||||
let choice = if action == "escape" {
|
||||
let events = app.view_stack.handle_key(KeyEvent::from(KeyCode::Esc));
|
||||
assert!(
|
||||
matches!(events.as_slice(), [ViewEvent::UserInputCancelled { tool_id }]
|
||||
if tool_id == &request_id)
|
||||
);
|
||||
// The host cancellation handler uses this same KeepPlanning path.
|
||||
PlanHandoffChoice::KeepPlanning
|
||||
} else {
|
||||
while app.view_stack.pop().is_some() {}
|
||||
if action == "revise" {
|
||||
PlanHandoffChoice::Revise("Add a validation step.".into())
|
||||
} else {
|
||||
PlanHandoffChoice::KeepPlanning
|
||||
}
|
||||
};
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
choice,
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(app.mode, AppMode::Plan, "{action}");
|
||||
assert!(app.pending_plan_handoff.is_none(), "{action}");
|
||||
assert_eq!(work.current_todos().await.unwrap(), current, "{action}");
|
||||
assert_eq!(app.todos.lock().await.snapshot(), current, "{action}");
|
||||
assert!(
|
||||
engine.rx_op.try_recv().is_err(),
|
||||
"no execution dispatch for {action}"
|
||||
);
|
||||
if action == "revise" {
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
assert_eq!(
|
||||
app.queued_messages.front().unwrap().display,
|
||||
"Add a validation step."
|
||||
);
|
||||
} else {
|
||||
assert_eq!(app.queued_message_count(), 0, "{action}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_refusal_preserves_changed_work_and_owned_pending_without_dispatch() {
|
||||
use crate::tools::todo::TodoStatus;
|
||||
let mut app = app_in_plan();
|
||||
app.offline_mode = true;
|
||||
app.todos
|
||||
.lock()
|
||||
.await
|
||||
.add("Unrelated work".into(), TodoStatus::Pending);
|
||||
let request_id = completed_plan(&mut app, "Build this approved report.", false);
|
||||
let mut engine = mock_engine_handle();
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(ApprovalMode::Suggest),
|
||||
reject_checkpoint_after_owned_seed_status_changes,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let seed = app.pending_plan_handoff.as_ref().unwrap().seeded_todo_id;
|
||||
assert!(seed.is_some());
|
||||
assert_eq!(app.queued_message_count(), 0);
|
||||
let work = app.runtime_services.work.as_ref().unwrap().clone();
|
||||
let changed = work.current_todos().await.unwrap();
|
||||
assert_eq!(changed.items[0].content, "Unrelated work");
|
||||
assert_eq!(changed.items[1].content, "Build this approved report.");
|
||||
assert_eq!(changed.items[1].status, TodoStatus::InProgress);
|
||||
while app.view_stack.pop().is_some() {}
|
||||
while let Ok(op) = engine.rx_op.try_recv() {
|
||||
assert!(matches!(op, crate::core::ops::Op::ChangeMode { .. }));
|
||||
}
|
||||
// Equal ID/content with a changed status is not unchanged ownership:
|
||||
// retrying Work must refuse before checkpoint or turn admission.
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(ApprovalMode::Suggest),
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.pending_plan_handoff.as_ref().unwrap().seeded_todo_id,
|
||||
seed
|
||||
);
|
||||
assert_eq!(work.current_todos().await.unwrap(), changed);
|
||||
assert_eq!(app.queued_message_count(), 0);
|
||||
while app.view_stack.pop().is_some() {}
|
||||
while let Ok(op) = engine.rx_op.try_recv() {
|
||||
assert!(
|
||||
matches!(op, crate::core::ops::Op::ChangeMode { .. }),
|
||||
"no execution after status change"
|
||||
);
|
||||
}
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::KeepPlanning,
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.pending_plan_handoff.as_ref().unwrap().seeded_todo_id,
|
||||
seed
|
||||
);
|
||||
assert!(
|
||||
!app.view_stack.is_empty(),
|
||||
"failed cleanup stays reviewable"
|
||||
);
|
||||
assert_eq!(work.current_todos().await.unwrap(), changed);
|
||||
assert_eq!(app.queued_message_count(), 0);
|
||||
assert!(engine.rx_op.try_recv().is_err());
|
||||
while app.view_stack.pop().is_some() {}
|
||||
app.current_session_id = Some("another-session".into());
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::KeepPlanning,
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
work.current_todos().await.unwrap(),
|
||||
changed,
|
||||
"stale cleanup must not touch another session"
|
||||
);
|
||||
assert_eq!(
|
||||
app.pending_plan_handoff.as_ref().unwrap().seeded_todo_id,
|
||||
seed
|
||||
);
|
||||
assert_eq!(app.queued_message_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_checklist_evidence_does_not_suppress_current_prose_seed() {
|
||||
use crate::tools::todo::TodoStatus;
|
||||
let mut app = app_in_plan();
|
||||
app.offline_mode = true;
|
||||
push_checklist_receipt(&mut app);
|
||||
app.tool_evidence.push(crate::tui::app::ToolEvidence {
|
||||
tool_name: "todo_write".into(),
|
||||
summary: "Earlier turn's checklist".into(),
|
||||
});
|
||||
app.todos
|
||||
.lock()
|
||||
.await
|
||||
.add("Earlier unrelated work".into(), TodoStatus::Pending);
|
||||
let request_id = completed_plan(&mut app, "Build this newly approved report.", false);
|
||||
assert!(
|
||||
!app.pending_plan_handoff
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.has_current_checklist
|
||||
);
|
||||
let engine = mock_engine_handle();
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(ApprovalMode::Suggest),
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let todos = app.todos.lock().await.snapshot();
|
||||
assert_eq!(todos.items.len(), 2);
|
||||
assert_eq!(todos.items[0].content, "Earlier unrelated work");
|
||||
assert_eq!(todos.items[1].content, "Build this newly approved report.");
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_cross_session_busy_and_permission_rejected_answers_seed_nothing() {
|
||||
for kind in [
|
||||
"stale-id",
|
||||
"session",
|
||||
"turn",
|
||||
"epoch",
|
||||
"busy",
|
||||
"draft",
|
||||
"permission",
|
||||
"engine",
|
||||
] {
|
||||
let mut app = app_in_plan();
|
||||
let mut request_id = completed_plan(&mut app, "Keep this exact approved plan.", false);
|
||||
let mut engine = mock_engine_handle();
|
||||
let posture = if kind == "permission" {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
ApprovalMode::Suggest
|
||||
};
|
||||
match kind {
|
||||
"stale-id" => request_id.push_str("-older"),
|
||||
"session" => app.current_session_id = Some("another-session".into()),
|
||||
"turn" => app.runtime_turn_id = Some("another-turn".into()),
|
||||
"epoch" => app.transcript_identity_epoch += 1,
|
||||
"busy" => app.is_loading = true,
|
||||
"draft" => app.input = "unsent draft".into(),
|
||||
"permission" => app.mark_approval_policy_locked(),
|
||||
"engine" => engine.rx_op.close(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Work(posture),
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(app.mode, AppMode::Plan, "{kind}");
|
||||
assert_eq!(app.queued_message_count(), 0, "{kind}");
|
||||
assert!(app.todos.lock().await.snapshot().is_empty(), "{kind}");
|
||||
assert!(
|
||||
!app.runtime_services
|
||||
.work
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.has_pending_publish(),
|
||||
"{kind}"
|
||||
);
|
||||
assert!(engine.rx_op.try_recv().is_err(), "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revise_keeps_plan_without_execution_seed() {
|
||||
let mut app = app_in_plan();
|
||||
app.offline_mode = true;
|
||||
let request_id = completed_plan(&mut app, "Original plan", false);
|
||||
let engine = mock_engine_handle();
|
||||
apply_plan_handoff_with_checkpoint(
|
||||
&mut app,
|
||||
&Config::default(),
|
||||
&engine.handle,
|
||||
&request_id,
|
||||
PlanHandoffChoice::Revise("先补充测试".into()),
|
||||
accept_checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(app.mode, AppMode::Plan);
|
||||
assert!(app.todos.lock().await.snapshot().is_empty());
|
||||
assert!(app.pending_plan_handoff.is_none());
|
||||
assert_eq!(app.queued_messages.front().unwrap().display, "先补充测试");
|
||||
}
|
||||
+7
-3
@@ -332,9 +332,13 @@ Or switch directly:
|
||||
|
||||
Plan mode is the safest place to start in an unfamiliar repository. It is for
|
||||
inspection and decision-making, not file edits.
|
||||
When a Plan turn ends with open To-do steps, Codewhale asks how to continue.
|
||||
**Work (Ask)** and **Work (Auto-Review)** switch to Work with that permission
|
||||
and start on the plan. **Keep planning**, or `Esc`, stays in Plan. You can also
|
||||
When a Plan turn successfully finishes a nonempty response, Codewhale asks
|
||||
how to continue, including when the plan is plain text. **Work (Ask)** and
|
||||
**Work (Auto-Review)** switch to Work with that permission and send the exact
|
||||
approved response as the next message. Existing To-dos from that turn are
|
||||
preserved; a prose-only plan adds one pending item without replacing unrelated
|
||||
work. If Codewhale cannot prepare the work state, it stays in Plan without
|
||||
starting the task. **Keep planning**, or `Esc`, stays in Plan. You can also
|
||||
type feedback there to have the plan revised. The question appears only when
|
||||
the composer is empty and no other view is open; `/mode work` always works.
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ Press `Ctrl+T` to cycle reasoning effort.
|
||||
Run `/mode` to open the mode picker, or switch directly with `/mode work`,
|
||||
`/mode plan`, or `/mode operate`.
|
||||
|
||||
- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn ends with open To-do steps, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning.
|
||||
- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.
|
||||
- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `bash`, `agent`, `workflow`, and `todo_write`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.
|
||||
- **Operate**: Work at full strength, the "ultra" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.
|
||||
|
||||
|
||||
@@ -220,8 +220,9 @@ Codewhale 有三种可见的 TUI 模式:
|
||||
/mode operate
|
||||
```
|
||||
|
||||
Plan 模式是在陌生仓库里开始的最安全位置。它用于检查和决策,不做文件编辑。当 Plan 回合结束且待办步骤尚未完成时,Codewhale 会询问如何继续。
|
||||
**Work(询问)** 和 **Work(自动审核)** 会以对应权限切换到 Work 并开始执行计划;**继续规划** 或 `Esc` 留在 Plan。你也可以在那里输入反馈来修订计划。只有在输入框为空且没有打开其他视图时才会出现这个提问;`/mode work` 始终可用。
|
||||
Plan 模式是在陌生仓库里开始的最安全位置。它用于检查和决策,不做文件编辑。当 Plan 回合成功完成非空回复时,Codewhale 会询问如何继续,即使计划只是普通文本。
|
||||
**Work(询问)** 和 **Work(自动审核)** 会以对应权限切换到 Work,并将获批回复的原文作为下一条消息发送。该回合已有的待办事项会保留;纯文本计划会添加一个待处理事项,不会替换其他工作。如果 Codewhale 无法准备工作状态,它会留在 Plan,不会开始执行任务。
|
||||
**继续规划** 或 `Esc` 留在 Plan。你也可以在那里输入反馈来修订计划。只有在输入框为空且没有打开其他视图时才会出现这个提问;`/mode work` 始终可用。
|
||||
|
||||
Work 模式是大多数贡献工作的默认模式。它允许 Codewhale 读文件、跑检查、编辑文件,同时把有风险的动作留在审批门禁之后。
|
||||
|
||||
|
||||
Reference in new issue
Block a user