1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! Logging utilities for `coding_agent_tools`.
//!
//! Provides a helper context that reduces duplication when logging tool calls
//! to the thoughts logs directory using `agentic_logging`.
use agentic_logging::CallTimer;
use agentic_logging::LogWriter;
use agentic_logging::ToolCallRecord;
use agentic_logging::chrono::DateTime;
use agentic_logging::chrono::Utc;
use thoughts_tool::active_logs_dir;
/// Context for logging a single tool call.
///
/// Captures timing, builds the log record, and writes it to the logs directory.
/// Logging is best-effort: if the logs directory is unavailable (e.g., branch lockout),
/// the tool call still succeeds without logging.
pub struct ToolLogCtx {
/// Timer for this call (contains `call_id` and `started_at`)
pub timer: CallTimer,
/// Writer instance (if logs dir was resolved)
writer: Option<LogWriter>,
/// Server name for the log record
server: String,
/// Tool name for the log record
tool: String,
}
impl ToolLogCtx {
/// Start a new logging context for a tool call.
///
/// If the logs directory cannot be resolved (e.g., branch lockout), the context
/// is still created but logging will be a no-op.
pub fn start(tool: &str) -> Self {
let timer = CallTimer::start();
let writer = active_logs_dir().ok().map(LogWriter::new);
Self {
timer,
writer,
server: "coding_agent_tools".to_string(),
tool: tool.to_string(),
}
}
/// Write a markdown response file and return the filename with timestamp.
///
/// Returns the filename and the `completed_at` timestamp used, so callers can
/// pass the same timestamp to `finish()` for consistent day-bucket placement.
///
/// Returns None if logging is unavailable or disabled.
pub fn write_markdown_response(&self, content: &str) -> Option<(String, DateTime<Utc>)> {
let writer = self.writer.as_ref()?;
let (completed_at, _) = self.timer.finish();
writer
.write_markdown_response(completed_at, &self.timer.call_id, content)
.ok()
.filter(|s| !s.is_empty())
.map(|filename| (filename, completed_at))
}
/// Finish the logging context and append the JSONL record.
///
/// If `completed_at` is provided (e.g., from `write_markdown_response`), it will be
/// used for the JSONL record to ensure consistent day-bucket placement. Otherwise,
/// a fresh timestamp is captured.
///
/// This is best-effort: errors are logged via tracing but do not fail the call.
pub fn finish(
self,
request: serde_json::Value,
response_file: Option<String>,
success: bool,
error: Option<String>,
summary: Option<serde_json::Value>,
model: Option<String>,
completed_at: Option<DateTime<Utc>>,
) {
let Some(writer) = self.writer else {
return;
};
// Use provided timestamp for consistency, or capture fresh one
let (completed_at, duration_ms) = match completed_at {
Some(ts) => (ts, self.timer.elapsed_ms()),
None => self.timer.finish(),
};
let record = ToolCallRecord {
call_id: self.timer.call_id,
server: self.server,
tool: self.tool,
started_at: self.timer.started_at,
completed_at,
duration_ms,
request,
response_file,
success,
error,
model,
token_usage: None,
summary,
};
if let Err(e) = writer.append_jsonl(&record) {
tracing::warn!("Failed to append JSONL log: {}", e);
}
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_tool_log_ctx_creation() {
// This will fail to get logs dir in test environment (no active branch),
// but should not panic
let ctx = ToolLogCtx::start("test_tool");
assert_eq!(ctx.tool, "test_tool");
assert_eq!(ctx.server, "coding_agent_tools");
assert!(!ctx.timer.call_id.is_empty());
}
#[test]
fn test_finish_without_writer_is_noop() {
// Create context (writer will be None without active branch)
let ctx = ToolLogCtx::start("test_tool");
// This should not panic even without a writer
ctx.finish(
serde_json::json!({"test": true}),
None,
true,
None,
None,
None,
None,
);
}
#[test]
fn test_write_markdown_does_not_panic() {
// Whether writer is available depends on test environment (active branch or not)
// The key invariant is that it should not panic either way
let ctx = ToolLogCtx::start("test_tool");
let result = ctx.write_markdown_response("# Test");
// Result may be Some (if logs dir available) or None (if not)
// Either outcome is valid - we just verify no panic
let _ = result;
}
// =========================================================================
// Request JSON shape tests - verify each tool logs the expected fields
// =========================================================================
#[test]
fn test_ask_agent_request_json_shape() {
let req = serde_json::json!({
"agent_type": "locator",
"location": "codebase",
"query": "Find all error handling code",
});
// Verify required fields exist
assert!(req.get("agent_type").is_some(), "Missing agent_type");
assert!(req.get("location").is_some(), "Missing location");
assert!(req.get("query").is_some(), "Missing query");
// Verify field types
assert!(req["agent_type"].is_string());
assert!(req["location"].is_string());
assert!(req["query"].is_string());
}
#[test]
fn test_ls_request_json_shape() {
let req = serde_json::json!({
"path": "./src",
"depth": 2,
"show": "files",
"ignore": ["*.tmp"],
"hidden": false,
});
// Verify expected fields
assert!(req.get("path").is_some(), "Missing path");
assert!(req.get("depth").is_some(), "Missing depth");
assert!(req.get("show").is_some(), "Missing show");
assert!(req.get("ignore").is_some(), "Missing ignore");
assert!(req.get("hidden").is_some(), "Missing hidden");
// Verify types
assert!(req["depth"].is_number() || req["depth"].is_null());
assert!(req["ignore"].is_array() || req["ignore"].is_null());
assert!(req["hidden"].is_boolean() || req["hidden"].is_null());
}
#[test]
fn test_ls_summary_json_shape() {
let summary = serde_json::json!({
"entries": 42,
"has_more": true,
"shown": 42,
"total": 100,
});
assert!(summary["entries"].is_number());
assert!(summary["has_more"].is_boolean());
assert!(summary["shown"].is_number());
assert!(summary["total"].is_number());
}
#[test]
fn test_search_grep_request_json_shape() {
let req = serde_json::json!({
"pattern": "fn main",
"path": "./src",
"mode": "content",
"globs": ["*.rs"],
"ignore": null,
"include_hidden": false,
"case_insensitive": true,
"multiline": false,
"line_numbers": true,
"context": 3,
"context_before": null,
"context_after": null,
"include_binary": false,
"head_limit": 200,
"offset": 0,
});
// Verify required fields
assert!(req.get("pattern").is_some(), "Missing pattern");
assert!(req["pattern"].is_string());
// Verify optional fields exist (may be null)
assert!(req.get("path").is_some());
assert!(req.get("mode").is_some());
assert!(req.get("globs").is_some());
assert!(req.get("head_limit").is_some());
assert!(req.get("offset").is_some());
}
#[test]
fn test_search_grep_summary_json_shape() {
let summary = serde_json::json!({
"lines": 15,
"mode": "content",
"has_more": false,
});
assert!(summary["lines"].is_number());
assert!(summary["mode"].is_string());
assert!(summary["has_more"].is_boolean());
}
#[test]
fn test_search_glob_request_json_shape() {
let req = serde_json::json!({
"pattern": "**/*.rs",
"path": ".",
"ignore": null,
"include_hidden": false,
"sort": "name",
"head_limit": 500,
"offset": 0,
});
assert!(req.get("pattern").is_some(), "Missing pattern");
assert!(req["pattern"].is_string());
assert!(req.get("head_limit").is_some());
assert!(req.get("offset").is_some());
}
#[test]
fn test_search_glob_summary_json_shape() {
let summary = serde_json::json!({
"entries": 25,
"has_more": false,
});
assert!(summary["entries"].is_number());
assert!(summary["has_more"].is_boolean());
}
#[test]
fn test_just_search_request_json_shape() {
let req = serde_json::json!({
"query": "test",
"dir": "./coding_agent_tools",
});
assert!(req.get("query").is_some());
assert!(req.get("dir").is_some());
}
#[test]
fn test_just_search_summary_json_shape() {
let summary = serde_json::json!({
"items": 5,
"has_more": true,
});
assert!(summary["items"].is_number());
assert!(summary["has_more"].is_boolean());
}
#[test]
fn test_just_execute_request_json_shape() {
let req = serde_json::json!({
"recipe": "test",
"dir": null,
"args": {"verbose": true},
});
assert!(req.get("recipe").is_some(), "Missing recipe");
assert!(req["recipe"].is_string());
assert!(req.get("dir").is_some());
assert!(req.get("args").is_some());
}
#[test]
fn test_just_execute_summary_json_shape() {
let summary = serde_json::json!({
"exit_code": 0,
"stdout_lines": 10,
"stderr_lines": 0,
});
assert!(summary["exit_code"].is_number());
assert!(summary["stdout_lines"].is_number());
assert!(summary["stderr_lines"].is_number());
}
#[test]
fn test_tool_call_record_has_expected_fields() {
// Use CallTimer to get proper timestamps
let timer = CallTimer::start();
let (completed_at, duration_ms) = timer.finish();
// Verify ToolCallRecord can be created with all fields used by coding_agent_tools
let record = ToolCallRecord {
call_id: "test-id".into(),
server: "coding_agent_tools".into(),
tool: "cli_ls".into(),
started_at: timer.started_at,
completed_at,
duration_ms,
request: serde_json::json!({"path": "."}),
response_file: None,
success: true,
error: None,
model: None, // coding_agent_tools doesn't use models (except ask_agent)
token_usage: None,
summary: Some(serde_json::json!({"entries": 10})),
};
let json = serde_json::to_string(&record).unwrap();
// Verify server is correct
assert!(json.contains("\"server\":\"coding_agent_tools\""));
// Verify summary is included
assert!(json.contains("\"summary\""));
// Verify model is omitted when None
assert!(!json.contains("\"model\""));
}
// =========================================================================
// Logging failure isolation tests
// =========================================================================
#[test]
fn test_logging_failure_does_not_affect_ctx_creation() {
// When active_logs_dir() fails (no active branch), ToolLogCtx should still be created
// with writer = None, allowing tools to proceed without logging
let ctx = ToolLogCtx::start("test_tool");
// Context is valid even without writer
assert_eq!(ctx.tool, "test_tool");
assert_eq!(ctx.server, "coding_agent_tools");
// Calling finish() should be a no-op, not a panic or error
ctx.finish(
serde_json::json!({"key": "value"}),
None,
true,
None,
Some(serde_json::json!({"result": "ok"})),
None,
None,
);
// Test passes if we reach here without panic
}
#[test]
fn test_logging_failure_isolation_with_error_result() {
// Even when logging an error result, if writer is unavailable, should not panic
let ctx = ToolLogCtx::start("failing_tool");
ctx.finish(
serde_json::json!({"input": "bad"}),
None,
false, // success = false
Some("Something went wrong".into()),
None,
None,
None,
);
// Test passes if we reach here without panic
}
#[test]
fn test_write_markdown_response_returns_none_gracefully() {
// When writer is unavailable, write_markdown_response should return None
// without panicking or causing errors
let ctx = ToolLogCtx::start("ask_agent");
// In test environment without active branch, this should return None
let result = ctx.write_markdown_response("# Large response\n\nSome content here...");
// Result is None when logging is unavailable (or Some when available)
// The key is that it doesn't panic or error
match result {
None => {
// Expected in environments without active branch
}
Some((filename, _completed_at)) => {
// Valid in environments with active branch
assert!(
std::path::Path::new(&filename)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
);
}
}
}
}