M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
//! Unit tests for the turn-finalize rails in [`super`] (`turn_completion`),
|
||||
//! split out via `#[path]` to keep the module itself small.
|
||||
|
||||
use super::*;
|
||||
use crate::app::agent::AgentState;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEventBlock;
|
||||
use crate::scrollback::state::ScrollbackState;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
fn last_session_event(sb: &ScrollbackState) -> Option<SessionEvent> {
|
||||
(0..sb.len())
|
||||
.rev()
|
||||
.find_map(|i| match sb.get(i).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(b)) => Some(b.event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A viewer in TurnRunning with an adopted prompt id, ready to be finalized.
|
||||
fn running_viewer(prompt_id: &str) -> AgentView {
|
||||
let mut agent = super::super::agent_view::test_agent_view(Some("s1"), PathBuf::from("/tmp"));
|
||||
agent.attached_as_viewer = true;
|
||||
agent.session.start_turn(&mut agent.scrollback);
|
||||
agent.session.current_prompt_id = Some(prompt_id.into());
|
||||
agent.turn_started_at = Some(Instant::now());
|
||||
agent
|
||||
}
|
||||
|
||||
/// A driver in TurnRunning with a local prompt id (default
|
||||
/// `attached_as_viewer == false`).
|
||||
fn running_driver(prompt_id: &str) -> AgentView {
|
||||
let mut agent = super::super::agent_view::test_agent_view(Some("s1"), PathBuf::from("/tmp"));
|
||||
agent.session.start_turn(&mut agent.scrollback);
|
||||
agent.session.current_prompt_id = Some(prompt_id.into());
|
||||
agent.turn_started_at = Some(Instant::now());
|
||||
agent
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_finalize_idles_and_pushes_completed_marker() {
|
||||
let mut agent = running_viewer("p1");
|
||||
let outcome =
|
||||
finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
|
||||
assert!(matches!(outcome, TerminalApply::ViewerFinalized));
|
||||
assert!(agent.session.state.is_idle());
|
||||
assert!(agent.session.current_prompt_id.is_none());
|
||||
assert!(agent.turn_started_at.is_none());
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnCompleted { .. })
|
||||
));
|
||||
}
|
||||
|
||||
fn one_stop_group() -> Vec<(String, Vec<crate::scrollback::blocks::tool::HookRunEntry>)> {
|
||||
use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus};
|
||||
vec![(
|
||||
"stop".to_string(),
|
||||
vec![HookRunEntry {
|
||||
name: "global/notify".into(),
|
||||
status: HookRunStatus::Success {
|
||||
elapsed: std::time::Duration::from_millis(12),
|
||||
},
|
||||
output: None,
|
||||
}],
|
||||
)]
|
||||
}
|
||||
|
||||
/// Stop-hook groups attached to the last session-event marker.
|
||||
fn last_marker_groups(sb: &ScrollbackState) -> Option<usize> {
|
||||
(0..sb.len())
|
||||
.rev()
|
||||
.find_map(|i| match sb.get(i).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(b)) => Some(b.stop_hooks.len()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn count_lifecycle_blocks(sb: &ScrollbackState) -> usize {
|
||||
use crate::scrollback::blocks::tool::ToolCallBlock;
|
||||
(0..sb.len())
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
sb.get(*i).map(|e| &e.block),
|
||||
Some(RenderBlock::ToolCall(ToolCallBlock::Lifecycle(_)))
|
||||
)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_push_consumes_matching_stop_hook_stash() {
|
||||
let mut agent = running_driver("p1");
|
||||
agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
|
||||
prompt_id: Some("p1".into()),
|
||||
groups: one_stop_group(),
|
||||
});
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(2)),
|
||||
}),
|
||||
Some("p1"),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
last_marker_groups(&agent.scrollback),
|
||||
Some(1),
|
||||
"the stash must fold into the marker"
|
||||
);
|
||||
assert!(agent.pending_stop_hooks.is_none());
|
||||
assert_eq!(count_lifecycle_blocks(&agent.scrollback), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_push_flushes_stale_stash_standalone() {
|
||||
// A stash stamped with another turn's prompt id must not attach to
|
||||
// this marker — it flushes as the legacy standalone block.
|
||||
let mut agent = running_driver("p2");
|
||||
agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
|
||||
prompt_id: Some("p1".into()),
|
||||
groups: one_stop_group(),
|
||||
});
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(2)),
|
||||
}),
|
||||
Some("p2"),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
last_marker_groups(&agent.scrollback),
|
||||
Some(0),
|
||||
"a stale stash must not attach to the new marker"
|
||||
);
|
||||
assert_eq!(count_lifecycle_blocks(&agent.scrollback), 1);
|
||||
assert!(agent.pending_stop_hooks.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_without_ending_pid_flushes_stamped_stash_standalone() {
|
||||
// A stamped stash can't be confirmed against a marker whose ending
|
||||
// turn id is missing — it flushes standalone instead of folding into
|
||||
// a marker it may not belong to.
|
||||
let mut agent = running_driver("p1");
|
||||
agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
|
||||
prompt_id: Some("p1".into()),
|
||||
groups: one_stop_group(),
|
||||
});
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(2)),
|
||||
}),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
last_marker_groups(&agent.scrollback),
|
||||
Some(0),
|
||||
"an unconfirmable stamped stash must not attach to the marker"
|
||||
);
|
||||
assert_eq!(count_lifecycle_blocks(&agent.scrollback), 1);
|
||||
assert!(agent.pending_stop_hooks.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_flushes_stash_as_standalone_block() {
|
||||
// Turn ends without a marker (bash turn / rate-limit UX): the held
|
||||
// hooks still surface, in the legacy standalone form.
|
||||
let mut agent = running_driver("p1");
|
||||
agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
|
||||
prompt_id: Some("p1".into()),
|
||||
groups: one_stop_group(),
|
||||
});
|
||||
|
||||
push_turn_terminal_marker(&mut agent, None, Some("p1"), false);
|
||||
|
||||
assert_eq!(count_lifecycle_blocks(&agent.scrollback), 1);
|
||||
assert!(agent.pending_stop_hooks.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_finalize_consumes_stop_hook_stash() {
|
||||
// A viewer that stashed hooks mid-turn folds them into the marker the
|
||||
// finalize pushes.
|
||||
let mut agent = running_viewer("p1");
|
||||
agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
|
||||
prompt_id: Some("p1".into()),
|
||||
groups: one_stop_group(),
|
||||
});
|
||||
|
||||
let _ = finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
|
||||
|
||||
assert_eq!(last_marker_groups(&agent.scrollback), Some(1));
|
||||
assert!(agent.pending_stop_hooks.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_finalize_duplicate_terminal_is_noop() {
|
||||
let mut agent = running_viewer("p1");
|
||||
let _ = finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
|
||||
let len_after_first = agent.scrollback.len();
|
||||
|
||||
// A duplicate/stale terminal for the now-finished turn does nothing.
|
||||
let outcome =
|
||||
finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
|
||||
assert!(matches!(outcome, TerminalApply::Ignored));
|
||||
assert!(agent.session.state.is_idle());
|
||||
assert_eq!(
|
||||
agent.scrollback.len(),
|
||||
len_after_first,
|
||||
"a duplicate terminal must not push a second marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_finalize_stop_reason_to_marker_mapping() {
|
||||
// cancelled → Turn cancelled.
|
||||
let mut agent = running_viewer("p1");
|
||||
let _ =
|
||||
finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("cancelled"), None, None);
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnCancelled { .. })
|
||||
));
|
||||
|
||||
// error (+agentResult) → Turn failed carrying the error text.
|
||||
let mut agent = running_viewer("p1");
|
||||
let _ = finalize_turn_from_terminal(
|
||||
&mut agent,
|
||||
"s1",
|
||||
Some("p1"),
|
||||
Some("error"),
|
||||
Some("boom"),
|
||||
None,
|
||||
);
|
||||
match last_session_event(&agent.scrollback) {
|
||||
Some(SessionEvent::TurnFailed { error, .. }) => assert_eq!(error, "boom"),
|
||||
other => panic!("expected TurnFailed, got {other:?}"),
|
||||
}
|
||||
|
||||
// rate_limit → finished, but no marker (not actionable from a viewer).
|
||||
let mut agent = running_viewer("p1");
|
||||
let _ =
|
||||
finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("rate_limit"), None, None);
|
||||
assert!(agent.session.state.is_idle());
|
||||
assert!(
|
||||
last_session_event(&agent.scrollback).is_none(),
|
||||
"rate_limit must not push a marker on a viewer"
|
||||
);
|
||||
|
||||
// unknown/other reason → Turn completed (the catch-all).
|
||||
let mut agent = running_viewer("p1");
|
||||
let _ =
|
||||
finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("max_tokens"), None, None);
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnCompleted { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_arms_reconcile_and_does_not_finish() {
|
||||
let mut agent = running_driver("p1");
|
||||
let outcome =
|
||||
finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("cancelled"), None, None);
|
||||
assert!(matches!(outcome, TerminalApply::ReconcileArmed));
|
||||
assert!(
|
||||
matches!(agent.session.state, AgentState::TurnRunning),
|
||||
"the driver's turn must NOT be finished — the PromptResponse RPC owns it"
|
||||
);
|
||||
let pending = agent
|
||||
.pending_turn_end_reconcile
|
||||
.as_ref()
|
||||
.expect("the driver's awaited turn must arm a reconcile");
|
||||
assert_eq!(pending.prompt_id, "p1");
|
||||
assert_eq!(pending.stop_reason.as_deref(), Some("cancelled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_mismatched_prompt_id_does_not_arm() {
|
||||
// Stale/peer terminal must not arm reconcile on a different live turn.
|
||||
let mut agent = running_driver("p1");
|
||||
let outcome = finalize_turn_from_terminal(
|
||||
&mut agent,
|
||||
"s1",
|
||||
Some("p-other"),
|
||||
Some("end_turn"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(matches!(outcome, TerminalApply::Ignored));
|
||||
assert!(agent.pending_turn_end_reconcile.is_none());
|
||||
assert!(matches!(agent.session.state, AgentState::TurnRunning));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_missing_prompt_id_arms_against_current_when_idle_in_turn() {
|
||||
let mut agent = running_driver("p1");
|
||||
let outcome = finalize_turn_from_terminal(&mut agent, "s1", None, Some("end_turn"), None, None);
|
||||
assert!(matches!(outcome, TerminalApply::ReconcileArmed));
|
||||
assert_eq!(
|
||||
agent.pending_turn_end_reconcile.as_ref().unwrap().prompt_id,
|
||||
"p1"
|
||||
);
|
||||
}
|
||||
|
||||
fn stream_agent_text(agent: &mut AgentView, text: &str) {
|
||||
use crate::acp::meta::NotificationMeta;
|
||||
use agent_client_protocol as acp;
|
||||
let meta = NotificationMeta::default();
|
||||
let _ = agent.session.tracker.handle_update(
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
|
||||
acp::TextContent::new(text),
|
||||
))),
|
||||
&meta,
|
||||
&mut agent.scrollback,
|
||||
);
|
||||
}
|
||||
|
||||
/// Missing wire pid still arms lost-PR reconcile (full teardown
|
||||
/// lives in `reconcile_overdue_turn_ends` / PromptResponse).
|
||||
#[test]
|
||||
fn repro_terminal_without_prompt_id_arms_reconcile_for_lost_pr() {
|
||||
let mut agent = running_driver("p1");
|
||||
stream_agent_text(&mut agent, "done");
|
||||
let outcome = finalize_turn_from_terminal(&mut agent, "s1", None, Some("end_turn"), None, None);
|
||||
assert!(matches!(outcome, TerminalApply::ReconcileArmed));
|
||||
assert!(agent.pending_turn_end_reconcile.is_some());
|
||||
assert!(
|
||||
matches!(agent.session.state, AgentState::TurnRunning),
|
||||
"driver stays TurnRunning until PR or overdue reconcile"
|
||||
);
|
||||
assert_eq!(
|
||||
agent.session.tracker.activity(),
|
||||
Some(crate::acp::tracker::TurnActivity::Responding)
|
||||
);
|
||||
}
|
||||
|
||||
/// Exact pid still arms (control).
|
||||
#[test]
|
||||
fn recovery_mode_matching_turn_completed_arms_reconcile_for_lost_pr() {
|
||||
let mut agent = running_driver("p1");
|
||||
stream_agent_text(&mut agent, "done");
|
||||
|
||||
let outcome =
|
||||
finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
|
||||
assert!(matches!(outcome, TerminalApply::ReconcileArmed));
|
||||
assert!(matches!(agent.session.state, AgentState::TurnRunning));
|
||||
assert!(agent.pending_turn_end_reconcile.is_some());
|
||||
}
|
||||
|
||||
/// Re-arm same pid keeps earliest received_at (does not extend grace forever).
|
||||
#[test]
|
||||
fn driver_rearm_same_pid_preserves_received_at() {
|
||||
let mut agent = running_driver("p1");
|
||||
let _ = finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
|
||||
let first = agent
|
||||
.pending_turn_end_reconcile
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.received_at;
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let _ = finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("end_turn"), None, None);
|
||||
let second = agent
|
||||
.pending_turn_end_reconcile
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.received_at;
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
// ── EndLine snapshots: work suffix + between-turns status window ──
|
||||
|
||||
use crate::scrollback::blocks::EndWork;
|
||||
|
||||
fn insert_bg_task(agent: &mut AgentView, task_id: &str, is_monitor: bool) {
|
||||
agent.session.bg_tasks.insert(
|
||||
task_id.into(),
|
||||
crate::app::agent::BgTaskState {
|
||||
task_id: task_id.into(),
|
||||
tool_call_id: format!("call-{task_id}"),
|
||||
command: "sleep 5".into(),
|
||||
description: None,
|
||||
cwd: "/tmp".into(),
|
||||
output_file: "/tmp/out".into(),
|
||||
status: crate::app::agent::BgTaskStatus::Running,
|
||||
start_time: std::time::SystemTime::now(),
|
||||
end_time: None,
|
||||
exit_code: None,
|
||||
signal: None,
|
||||
stdout: String::new(),
|
||||
stdout_line_count: 0,
|
||||
truncated: false,
|
||||
pending_kill: false,
|
||||
kill_requested_at: None,
|
||||
scrollback_entry_id: None,
|
||||
is_monitor,
|
||||
restored_from_replay: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The newest session-event marker block.
|
||||
fn last_marker_block(agent: &AgentView) -> &SessionEventBlock {
|
||||
(0..agent.scrollback.len())
|
||||
.rev()
|
||||
.find_map(|i| match agent.scrollback.get(i).map(|e| &e.block) {
|
||||
Some(RenderBlock::SessionEvent(b)) => Some(b),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a session-event marker must exist")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_end_marker_snapshots_running_work() {
|
||||
let mut agent = running_driver("p1");
|
||||
insert_bg_task(&mut agent, "bg-1", false);
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(2)),
|
||||
}),
|
||||
Some("p1"),
|
||||
false,
|
||||
);
|
||||
|
||||
let block = last_marker_block(&agent);
|
||||
assert!(!block.parked);
|
||||
assert_eq!(block.prompt_id.as_deref(), Some("p1"));
|
||||
assert_eq!(
|
||||
block.end_work,
|
||||
Some(EndWork {
|
||||
running_commands: 1,
|
||||
..EndWork::default()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
block.marker_text(),
|
||||
"Worked for 2.0s. 1 command still running."
|
||||
);
|
||||
assert!(
|
||||
agent.end_work_announced,
|
||||
"announcing work opens the between-turns status window"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workless_marker_stays_legacy_and_closes_window() {
|
||||
let mut agent = running_driver("p1");
|
||||
agent.end_work_announced = true;
|
||||
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(2)),
|
||||
}),
|
||||
Some("p1"),
|
||||
false,
|
||||
);
|
||||
|
||||
let block = last_marker_block(&agent);
|
||||
assert!(block.end_work.is_none(), "legacy marker: no work suffix");
|
||||
assert_eq!(block.marker_text(), "Worked for 2.0s.");
|
||||
assert!(
|
||||
!agent.end_work_announced,
|
||||
"a workless marker proves nothing is running — window closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_snapshot_never_mutates() {
|
||||
// The suffix is a push-time snapshot: later completions re-emit a
|
||||
// fresh status line instead of editing the marker.
|
||||
let mut agent = running_driver("p1");
|
||||
insert_bg_task(&mut agent, "bg-1", false);
|
||||
push_turn_terminal_marker(
|
||||
&mut agent,
|
||||
Some(SessionEvent::TurnCompleted {
|
||||
elapsed: Some(std::time::Duration::from_secs(2)),
|
||||
}),
|
||||
Some("p1"),
|
||||
false,
|
||||
);
|
||||
|
||||
agent.session.bg_tasks.get_mut("bg-1").unwrap().status = crate::app::agent::BgTaskStatus::Done;
|
||||
|
||||
assert_eq!(
|
||||
last_marker_block(&agent).marker_text(),
|
||||
"Worked for 2.0s. 1 command still running.",
|
||||
"the marker keeps its push-time counts"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Send-now cancel marker suppression (viewer finalize rail) ────────
|
||||
|
||||
/// A viewer finalizing a `send_now`-stamped `cancelled` terminal pushes no marker.
|
||||
#[test]
|
||||
fn viewer_finalize_suppresses_send_now_cancel_marker() {
|
||||
let mut agent = running_viewer("p1");
|
||||
let outcome = finalize_turn_from_terminal(
|
||||
&mut agent,
|
||||
"s1",
|
||||
Some("p1"),
|
||||
Some("cancelled"),
|
||||
None,
|
||||
Some("send_now"),
|
||||
);
|
||||
assert!(matches!(outcome, TerminalApply::ViewerFinalized));
|
||||
assert!(agent.session.state.is_idle(), "the turn still finishes");
|
||||
assert!(
|
||||
last_session_event(&agent.scrollback).is_none(),
|
||||
"a send-now cancel pushes no marker on a viewer"
|
||||
);
|
||||
|
||||
// A non-send-now trigger keeps the marker even with a local expectation armed (wire is authoritative).
|
||||
let mut agent = running_viewer("p1");
|
||||
agent.expect_send_now_cancel = Some("p-mine".into());
|
||||
let _ = finalize_turn_from_terminal(
|
||||
&mut agent,
|
||||
"s1",
|
||||
Some("p1"),
|
||||
Some("cancelled"),
|
||||
None,
|
||||
Some("ctrl_c"),
|
||||
);
|
||||
assert!(matches!(
|
||||
last_session_event(&agent.scrollback),
|
||||
Some(SessionEvent::TurnCancelled { .. })
|
||||
));
|
||||
|
||||
// Older shell (no meta): the armed expectation is the fallback.
|
||||
let mut agent = running_viewer("p1");
|
||||
agent.expect_send_now_cancel = Some("p-mine".into());
|
||||
let _ =
|
||||
finalize_turn_from_terminal(&mut agent, "s1", Some("p1"), Some("cancelled"), None, None);
|
||||
assert!(
|
||||
last_session_event(&agent.scrollback).is_none(),
|
||||
"the armed expectation suppresses the marker without wire meta"
|
||||
);
|
||||
assert!(
|
||||
agent.expect_send_now_cancel.is_none(),
|
||||
"the expectation is consumed on the viewer finalize"
|
||||
);
|
||||
}
|
||||
|
||||
/// The driver arm records the broadcast's `cancelTrigger` for a lost-RPC reconcile.
|
||||
#[test]
|
||||
fn driver_arm_records_cancel_trigger_for_reconcile() {
|
||||
let mut agent = running_driver("p1");
|
||||
let outcome = finalize_turn_from_terminal(
|
||||
&mut agent,
|
||||
"s1",
|
||||
Some("p1"),
|
||||
Some("cancelled"),
|
||||
None,
|
||||
Some("send_now"),
|
||||
);
|
||||
assert!(matches!(outcome, TerminalApply::ReconcileArmed));
|
||||
assert_eq!(
|
||||
agent
|
||||
.pending_turn_end_reconcile
|
||||
.as_ref()
|
||||
.and_then(|p| p.cancel_trigger.as_deref()),
|
||||
Some("send_now")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user