1use std::ffi::OsStr;
9use std::io::{self, Write};
10use std::path::Path;
11use std::process::{Command, Stdio};
12
13use flate2::Compression;
14use flate2::write::DeflateEncoder;
15use serde_json::{Value, json};
16use yapi_ai::registry::ModelRegistry;
17use yapi_types::message::{Message, StopReason, blocks_text};
18use yapi_types::model::Model;
19use yapi_types::session::FileEntry;
20
21use crate::agent_session::AgentSession;
22use crate::compaction::{Summarizer, check_summary, estimate_tokens, serialize_conversation};
23use crate::messages::convert_to_llm;
24use crate::session::SessionManager;
25use crate::settings::Scope;
26use crate::time::{now_iso, now_ms, uuid_v7};
27
28pub const ENTRY_TYPE: &str = "yapi.bug-report";
30pub const REPO: &str = "SkymanOne/yapi";
32pub const ISSUES_URL: &str = "https://github.com/SkymanOne/yapi/issues";
34
35const SCHEMA_VERSION: u32 = 1;
36const REDACTED: &str = "<redacted>";
37const SENSITIVE_WORDS: [&str; 7] = [
39 "secret",
40 "token",
41 "password",
42 "passwd",
43 "credential",
44 "authorization",
45 "cookie",
46];
47const BODY_LIMIT: usize = 65_536;
49const TITLE_LIMIT: usize = 256;
51
52fn is_sensitive_key(key: &str) -> bool {
56 let mut split = String::new();
57 let mut previous: Option<char> = None;
58 for c in key.chars() {
59 if c.is_ascii_uppercase()
60 && previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
61 {
62 split.push('_');
63 }
64 split.push(c.to_ascii_lowercase());
65 previous = Some(c);
66 }
67 let words: Vec<&str> = split.split(['-', '_']).collect();
68 words.iter().enumerate().any(|(index, word)| {
69 SENSITIVE_WORDS.contains(word)
70 || *word == "apikey"
71 || (*word == "api" && words.get(index + 1) == Some(&"key"))
72 })
73}
74
75fn scheme_len(text: &str) -> Option<usize> {
78 let end = text.find(':')?;
79 let mut chars = text[..end].chars();
80 let valid = chars.next()?.is_ascii_alphabetic()
81 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'));
82 valid.then_some(end + 1)
83}
84
85fn redact_url(value: &str) -> String {
89 if let Some(prefix) = scheme_len(value) {
91 let rest = &value[prefix..];
92 if scheme_len(rest).is_some_and(|end| rest[end..].starts_with("//")) {
93 return format!("{}{}", &value[..prefix], redact_url(rest));
94 }
95 }
96 let Ok(mut url) = url::Url::parse(value) else {
97 return value.to_owned();
98 };
99 let mut changed = false;
100 if !url.username().is_empty() || url.password().is_some() {
101 let _ = url.set_username("");
102 let _ = url.set_password(None);
103 changed = true;
104 }
105 if url.query_pairs().any(|(key, _)| is_sensitive_key(&key)) {
106 let pairs: Vec<(String, String)> = url
107 .query_pairs()
108 .map(|(key, value)| {
109 let value = if is_sensitive_key(&key) {
110 REDACTED.to_owned()
111 } else {
112 value.into_owned()
113 };
114 (key.into_owned(), value)
115 })
116 .collect();
117 url.query_pairs_mut().clear().extend_pairs(pairs);
118 changed = true;
119 }
120 if changed {
121 url.into()
122 } else {
123 value.to_owned()
124 }
125}
126
127fn redact(value: &mut Value) {
130 match value {
131 Value::Object(map) => {
132 for (key, child) in map.iter_mut() {
133 if !child.is_null() && is_sensitive_key(key) {
134 *child = Value::String(REDACTED.to_owned());
135 } else {
136 redact(child);
137 }
138 }
139 }
140 Value::Array(items) => items.iter_mut().for_each(redact),
141 Value::String(text) => *text = redact_url(text),
142 _ => {}
143 }
144}
145
146fn node_platform() -> (&'static str, &'static str) {
149 let platform = match std::env::consts::OS {
150 "macos" => "darwin",
151 "windows" => "win32",
152 os => os,
153 };
154 let arch = match std::env::consts::ARCH {
155 "x86_64" => "x64",
156 "aarch64" => "arm64",
157 "x86" => "ia32",
158 arch => arch,
159 };
160 (platform, arch)
161}
162
163#[cfg(unix)]
165fn os_release() -> (Option<String>, Option<String>) {
166 let name = rustix::system::uname();
167 let text = |value: &std::ffi::CStr| Some(value.to_string_lossy().into_owned());
168 (text(name.release()), text(name.version()))
169}
170
171#[cfg(not(unix))]
172fn os_release() -> (Option<String>, Option<String>) {
173 (None, None)
174}
175
176fn environment() -> Value {
178 let env = |name: &str| std::env::var(name).ok().filter(|value| !value.is_empty());
179 let mut names: Vec<String> = std::env::vars_os()
180 .filter_map(|(name, _)| name.into_string().ok())
181 .filter(|name| name.starts_with("PI_") || name.starts_with("YAPI_"))
182 .collect();
183 names.sort();
184 let version = env!("CARGO_PKG_VERSION");
185 let build = if cfg!(debug_assertions) {
186 "debug"
187 } else {
188 "release"
189 };
190 let (platform, arch) = node_platform();
191 let (os_release, os_version) = os_release();
192 let ssh = ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]
193 .into_iter()
194 .any(|name| env(name).is_some());
195 json!({
196 "version": version,
197 "userAgent": yapi_ai::http::user_agent(),
198 "runtime": format!("yapi/{version} ({build})"),
199 "platform": platform,
200 "arch": arch,
201 "osRelease": os_release,
202 "osVersion": os_version,
203 "shell": env("SHELL").and_then(|shell| {
204 shell.rsplit(['/', '\\']).next().filter(|name| !name.is_empty()).map(str::to_owned)
205 }),
206 "terminal": {
207 "term": env("TERM"),
208 "program": env("TERM_PROGRAM"),
209 "programVersion": env("TERM_PROGRAM_VERSION"),
210 "colorterm": env("COLORTERM"),
211 "tmux": env("TMUX").is_some(),
212 "ssh": ssh,
213 "ci": env("CI").is_some(),
214 },
215 "piEnvironmentVariables": names,
217 })
218}
219
220fn sorted_names<'a>(headers: impl IntoIterator<Item = &'a String>) -> Vec<&'a String> {
221 let mut names: Vec<&String> = headers.into_iter().collect();
222 names.sort();
223 names
224}
225
226fn describe_model(model: &Model) -> Value {
227 json!({
228 "provider": model.provider,
229 "id": model.id,
230 "name": model.name,
231 "api": model.api,
232 "baseUrl": model.base_url,
233 "reasoning": model.reasoning,
234 "input": model.input,
235 "contextWindow": model.context_window,
236 "maxTokens": model.max_tokens,
237 "samplingParams": model.sampling_params,
238 "compat": model.compat,
239 "thinkingLevelMap": model.thinking_level_map,
240 "headerNames": sorted_names(model.headers.iter().flat_map(|headers| headers.keys())),
241 })
242}
243
244fn describe_provider(registry: &ModelRegistry, id: &str) -> Value {
245 let config = registry.provider_config(id);
246 let mut auth_types = Vec::new();
247 if yapi_ai::providers::info(id).is_none_or(|info| info.api_key.is_some()) {
248 auth_types.push("api_key");
249 }
250 if registry.oauth_flow(id).is_some() {
251 auth_types.push("oauth");
252 }
253 let auth_status = match registry.login_status(id) {
254 Some(source) => json!({"configured": true, "source": source}),
255 None => json!({"configured": false}),
256 };
257 json!({
258 "id": id,
259 "name": registry.provider_name(id),
260 "baseUrl": config.and_then(|config| config.base_url.clone()),
261 "headerNames": sorted_names(config.and_then(|config| config.headers.as_ref()).into_iter().flat_map(|headers| headers.keys())),
262 "authTypes": auth_types,
263 "authStatus": auth_status,
264 "usingOAuth": registry.is_using_oauth(id),
265 "registeredByExtension": registry.registered_by_extension(id),
266 })
267}
268
269fn settings(session: &AgentSession, scope: Scope) -> Value {
271 let mut document = session.settings_manager().document(scope).clone();
272 document.retain(|key, _| key != "trackingId" && key != "deviceId");
273 Value::Object(document)
274}
275
276fn diagnostics(session: &SessionManager) -> Value {
279 let branch = session.branch_path(None);
280 let mut assistant = Vec::new();
281 let mut count = 0;
282 for entry in &branch {
283 let FileEntry::Message(entry) = entry else {
284 continue;
285 };
286 let Message::Assistant(message) = &entry.message else {
287 continue;
288 };
289 count += 1;
290 let diagnostics = message.diagnostics.clone().unwrap_or_default();
291 let failed = matches!(message.stop_reason, StopReason::Error | StopReason::Aborted)
292 || message
293 .error_message
294 .as_deref()
295 .is_some_and(|m| !m.is_empty());
296 if diagnostics.is_empty() && !failed {
297 continue;
298 }
299 let mut turn = json!({
300 "entryId": entry.meta.id,
301 "timestamp": entry.meta.timestamp,
302 "provider": message.provider,
303 "model": message.model,
304 "api": message.api,
305 "stopReason": message.stop_reason,
306 });
307 if let Some(reason) = &message.raw_stop_reason {
308 turn["rawStopReason"] = json!(reason);
309 }
310 if let Some(error) = &message.error_message {
311 turn["errorMessage"] = json!(error);
312 }
313 turn["diagnostics"] = json!(diagnostics);
314 assistant.push(turn);
315 }
316 json!({
317 "schemaVersion": SCHEMA_VERSION,
318 "sessionId": session.id(),
319 "entryCount": branch.len(),
320 "assistantMessageCount": count,
321 "assistant": assistant,
322 })
323}
324
325pub struct Report {
327 pub id: String,
329 metadata: Value,
332 diagnostics: Value,
334 session: Option<String>,
336 summary: Option<String>,
338}
339
340impl Report {
341 pub fn collect(
345 session: &AgentSession,
346 hint: &str,
347 include_session: bool,
348 summary: Option<String>,
349 ) -> Report {
350 let id = uuid_v7();
351 let (session_id, cwd, jsonl, mut diagnostics) = session.with_session(|file| {
352 (
353 file.id().to_owned(),
354 file.cwd().display().to_string(),
355 include_session.then(|| file.serialize_branch()),
356 diagnostics(file),
357 )
358 });
359 let mut session_info = json!({
360 "id": session_id,
361 "included": include_session,
362 "summaryIncluded": summary.is_some(),
363 "messageCount": session.messages().len(),
364 });
365 if include_session {
366 session_info["cwd"] = json!(cwd);
367 }
368 let model = session.model();
369 let registry = session.registry();
370 let extensions: Vec<Value> = session
371 .extensions()
372 .iter()
373 .map(|extension| {
374 let source = extension.source();
375 let hidden = source.source == "builtin";
377 json!({
378 "path": source.path,
379 "source": source.source,
380 "scope": source.scope,
381 "origin": source.origin,
382 "hidden": hidden,
383 })
384 })
385 .collect();
386 let errors: Vec<Value> = session
387 .extension_load_errors()
388 .into_iter()
389 .map(|(path, error)| json!({"path": path, "error": error}))
390 .collect();
391 let hint = hint.trim();
392 let mut metadata = json!({
393 "schemaVersion": SCHEMA_VERSION,
394 "id": id,
395 "createdAt": now_iso(),
396 "hint": (!hint.is_empty()).then_some(hint),
397 "environment": environment(),
398 "session": session_info,
399 "model": model.as_ref().map(describe_model),
400 "provider": model.as_ref().map(|model| describe_provider(®istry, &model.provider)),
401 "thinkingLevel": session.thinking_level().as_str(),
402 "extensions": extensions,
403 "extensionErrors": errors,
404 "settings": {
405 "global": settings(session, Scope::Global),
406 "project": settings(session, Scope::Project),
407 },
408 });
409 redact(&mut metadata);
410 redact(&mut diagnostics);
411 Report {
412 id,
413 metadata,
414 diagnostics,
415 session: jsonl,
416 summary,
417 }
418 }
419
420 pub fn file_name(&self) -> String {
422 format!("yapi-bug-report-{}.zip", self.id)
423 }
424
425 fn hint(&self) -> Option<&str> {
427 self.metadata["hint"].as_str()
428 }
429
430 fn files(&self) -> Vec<(&'static str, String)> {
432 let mut files = vec![
433 ("report.json", pretty(&self.metadata)),
434 ("diagnostics.json", pretty(&self.diagnostics)),
435 ];
436 if let Some(session) = &self.session {
437 files.push(("session.jsonl", session.clone()));
438 }
439 if let Some(summary) = &self.summary {
440 let newline = if summary.ends_with('\n') { "" } else { "\n" };
441 files.push(("summary.md", format!("{summary}{newline}")));
442 }
443 files
444 }
445
446 pub fn write_zip(&self, path: &Path) -> io::Result<()> {
448 std::fs::write(path, zip(&self.files(), dos_time(now_ms()))?)
449 }
450
451 pub fn title(&self) -> String {
453 match self.hint().and_then(|hint| hint.lines().next()) {
454 Some(line) => line.chars().take(TITLE_LIMIT).collect(),
455 None => format!("yapi bug report {}", self.id),
456 }
457 }
458
459 pub fn issue_body(&self, home: &Path) -> String {
464 let mut head = self.hint().unwrap_or("No description given.").to_owned();
465 if let Some(summary) = &self.summary {
466 head.push_str(&format!("\n\n## Summary\n\n{summary}"));
467 }
468 let mut metadata = self.metadata.clone();
469 if let Some(scopes) = metadata["settings"].as_object_mut() {
472 for scope in scopes.values_mut() {
473 if let Some(settings) = scope.as_object() {
474 *scope = settings.keys().cloned().collect();
475 }
476 }
477 }
478 let report = pretty(&metadata);
479 let longest = report.split(|c| c != '`').map(str::len).max().unwrap_or(0);
480 let fence = "`".repeat(longest.max(2) + 1);
481 let open = format!("\n\n## Report\n\n{fence}json\n");
482 let close = format!("{fence}\n");
483 let head = truncate(&head, BODY_LIMIT / 2);
484 let room = BODY_LIMIT - head.len() - open.len() - close.len();
485 tilde(
487 &format!("{head}{open}{}{close}", truncate(&report, room)),
488 home,
489 )
490 }
491
492 pub fn entry(&self, path: &Path, issue_url: Option<&str>) -> Value {
495 let mut entry = json!({
496 "id": self.id,
497 "createdAt": self.metadata["createdAt"],
498 "hint": self.metadata["hint"],
499 "sessionIncluded": self.session.is_some(),
500 "summaryIncluded": self.summary.is_some(),
501 "delivery": "zip",
502 "path": path.display().to_string(),
503 });
504 if let Some(url) = issue_url {
505 entry["issueUrl"] = json!(url);
506 }
507 entry
508 }
509}
510
511fn tilde(text: &str, home: &Path) -> String {
514 let home = home.to_string_lossy();
515 let home = home.trim_end_matches('/');
516 if home.is_empty() {
517 return text.to_owned();
518 }
519 let mut out = String::with_capacity(text.len());
520 let mut rest = text;
521 while let Some(at) = rest.find(home) {
522 let after = &rest[at + home.len()..];
523 let whole = !after.starts_with(|c: char| c.is_alphanumeric() || "-_.".contains(c));
524 out.push_str(&rest[..at]);
525 out.push_str(if whole { "~" } else { home });
526 rest = after;
527 }
528 out.push_str(rest);
529 out
530}
531
532fn pretty(value: &Value) -> String {
534 yapi_types::json::to_string_pretty(value, " ").unwrap_or_default() + "\n"
535}
536
537fn truncate(text: &str, max: usize) -> String {
539 const MARK: &str = "\n[truncated]\n";
540 if text.len() <= max {
541 return text.to_owned();
542 }
543 let end = text.floor_char_boundary(max.saturating_sub(MARK.len()));
544 format!("{}{MARK}", &text[..end])
545}
546
547fn dos_time(ms: u64) -> (u16, u16) {
549 let seconds = (ms / 1000) as i64;
550 let local = seconds + i64::from(crate::zone::local().offset(seconds));
551 let iso = crate::time::iso(local.max(0) as u64 * 1000);
552 let field = |at: usize, len: usize| {
553 iso.get(at..at + len)
554 .and_then(|text| text.parse::<u16>().ok())
555 .unwrap_or(0)
556 };
557 let (year, month, day) = (field(0, 4), field(5, 2), field(8, 2));
558 let (hour, minute, second) = (field(11, 2), field(14, 2), field(17, 2));
559 (
560 hour << 11 | minute << 5 | second >> 1,
561 (year.max(1980) - 1980) << 9 | month << 5 | day,
562 )
563}
564
565fn zip(files: &[(&str, String)], (time, date): (u16, u16)) -> io::Result<Vec<u8>> {
569 let mut out = Vec::new();
570 let mut directory = Vec::new();
571 for (name, data) in files {
572 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
573 encoder.write_all(data.as_bytes())?;
574 let compressed = encoder.finish()?;
575 let mut crc = flate2::Crc::new();
576 crc.update(data.as_bytes());
577 let offset = out.len() as u32;
578 let fields = |header: &mut Vec<u8>| {
580 for value in [20, 0x0800, 8, time, date] {
581 header.extend(value.to_le_bytes());
582 }
583 for value in [crc.sum(), compressed.len() as u32, data.len() as u32] {
584 header.extend(value.to_le_bytes());
585 }
586 header.extend((name.len() as u16).to_le_bytes());
587 header.extend(0u16.to_le_bytes());
588 };
589 out.extend(0x0403_4b50u32.to_le_bytes());
590 fields(&mut out);
591 out.extend(name.as_bytes());
592 out.extend(&compressed);
593 directory.extend(0x0201_4b50u32.to_le_bytes());
594 directory.extend(20u16.to_le_bytes());
595 fields(&mut directory);
596 directory.extend([0; 10]);
598 directory.extend(offset.to_le_bytes());
599 directory.extend(name.as_bytes());
600 }
601 let count = (files.len() as u16).to_le_bytes();
602 let (directory_len, directory_at) = (directory.len() as u32, out.len() as u32);
603 out.extend(directory);
604 out.extend(0x0605_4b50u32.to_le_bytes());
605 out.extend([0; 4]);
606 out.extend(count);
607 out.extend(count);
608 out.extend(directory_len.to_le_bytes());
609 out.extend(directory_at.to_le_bytes());
610 out.extend([0; 2]);
611 Ok(out)
612}
613
614const SUMMARY_SYSTEM_PROMPT: &str = "You are helping a user file a bug report about yapi, the coding agent they are talking to. You will be shown the conversation transcript. Write a report for the yapi developers describing what the user was doing and what went wrong.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the report.";
615
616const SUMMARY_INSTRUCTIONS: &str = "Write the bug report in Markdown with these sections:\n\n## What the user was doing\nOne short paragraph.\n\n## What went wrong\nConcrete description of the failure: wrong output, errors, hangs, tool failures, unexpected behavior. Quote error messages and tool output verbatim where they exist.\n\n## Steps to reproduce\nNumbered list, as specific as the transcript allows.\n\n## Relevant details\nTool calls involved, files touched, model behavior, anything else that helps a developer reproduce or locate the problem.\n\nDo not include file contents, secrets, or credentials from the transcript; refer to files by path only. The report is public: write paths inside the user's home folder as ~/..., even in quoted errors, and never state the user's name or where their home folder is. Keep the report factual and concise.";
617
618pub(crate) async fn summary(
622 summarizer: &Summarizer<'_>,
623 messages: &[Message],
624 hint: Option<&str>,
625) -> Result<String, String> {
626 let model = summarizer.model;
627 let window = if model.context_window > 0 {
628 model.context_window
629 } else {
630 128_000
631 };
632 let budget = window * 6 / 10;
633 let (mut start, mut tokens) = (messages.len(), 0);
634 while start > 0 {
635 let next = estimate_tokens(&messages[start - 1]);
636 if start < messages.len() && tokens + next > budget {
637 break;
638 }
639 tokens += next;
640 start -= 1;
641 }
642 let shown = &messages[start..];
643 let mut parts = Vec::new();
644 if start > 0 {
645 parts.push(format!(
646 "Note: only the last {} of {} messages are shown.",
647 shown.len(),
648 messages.len()
649 ));
650 }
651 let conversation = serialize_conversation(&convert_to_llm(shown.to_vec()));
652 parts.push(format!("<conversation>\n{conversation}\n</conversation>"));
653 if let Some(hint) = hint.map(str::trim).filter(|hint| !hint.is_empty()) {
654 parts.push(format!("<user-report>\n{hint}\n</user-report>"));
655 }
656 parts.push(SUMMARY_INSTRUCTIONS.to_owned());
657 let max_tokens = if model.max_tokens > 0 {
658 model.max_tokens.min(4096)
659 } else {
660 4096
661 };
662 let response = summarizer
663 .complete(SUMMARY_SYSTEM_PROMPT, &parts.join("\n\n"), max_tokens)
664 .await;
665 if response.stop_reason == StopReason::Aborted {
666 return Err("Bug report summary was cancelled".to_owned());
667 }
668 check_summary(&response, "Bug report summary")?;
669 let text = blocks_text(&response.content, "\n").trim().to_owned();
670 if text.is_empty() {
671 return Err("Bug report summary was empty".to_owned());
672 }
673 Ok(text)
674}
675
676pub fn github_ready(program: &OsStr, offline: bool) -> bool {
679 !offline
680 && Command::new(program)
681 .args(["auth", "status"])
682 .stdin(Stdio::null())
683 .stdout(Stdio::null())
684 .stderr(Stdio::null())
685 .status()
686 .is_ok_and(|status| status.success())
687}
688
689pub fn create_issue(
693 program: &OsStr,
694 title: &str,
695 body: &str,
696 body_file: &Path,
697) -> Result<String, String> {
698 std::fs::write(body_file, body).map_err(|error| error.to_string())?;
699 let output = Command::new(program)
700 .args([
701 "issue",
702 "create",
703 "--repo",
704 REPO,
705 "--title",
706 title,
707 "--body-file",
708 ])
709 .arg(body_file)
710 .stdin(Stdio::null())
711 .output();
712 let _ = std::fs::remove_file(body_file);
713 let output = output.map_err(|error| error.to_string())?;
714 if !output.status.success() {
715 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
716 return Err(if stderr.is_empty() {
717 format!("gh failed with {}", output.status)
718 } else {
719 stderr
720 });
721 }
722 let stdout = String::from_utf8_lossy(&output.stdout);
723 Ok(stdout.lines().last().unwrap_or_default().trim().to_owned())
724}
725
726#[cfg(test)]
727mod tests {
728 use std::io::Read;
729
730 use super::*;
731
732 #[test]
733 fn redacts_credentials() {
734 let mut value = json!({
735 "apiKey": "sk-1",
736 "nested": {"x-api-key": "sk-2", "Authorization": "Bearer t", "maxTokens": 5},
737 "cookies": "kept",
738 "proxy": "https://user:pass@proxy.example/path?access_token=abc&q=1",
739 "source": "git:https://me:tok@github.com/o/r",
740 "plain": "not a url",
741 "empty": null,
742 "list": [{"refreshToken": "r"}],
743 });
744 redact(&mut value);
745 assert_eq!(
746 value,
747 json!({
748 "apiKey": "<redacted>",
749 "nested": {"x-api-key": "<redacted>", "Authorization": "<redacted>", "maxTokens": 5},
750 "cookies": "kept",
751 "proxy": "https://proxy.example/path?access_token=%3Credacted%3E&q=1",
752 "source": "git:https://github.com/o/r",
753 "plain": "not a url",
754 "empty": null,
755 "list": [{"refreshToken": "<redacted>"}],
756 })
757 );
758 assert_eq!(
759 redact_url("https://example.com/a?b=c"),
760 "https://example.com/a?b=c"
761 );
762 }
763
764 fn report(session: Option<&str>, summary: Option<&str>) -> Report {
765 Report {
766 id: "0190-id".into(),
767 metadata: json!({"id": "0190-id", "hint": "It broke\nafter /reload", "createdAt": "t"}),
768 diagnostics: json!({"assistant": [{"errorMessage": "DIAGNOSTIC-MARKER"}]}),
769 session: session.map(str::to_owned),
770 summary: summary.map(str::to_owned),
771 }
772 }
773
774 fn unzip(bytes: &[u8]) -> Vec<(String, String)> {
776 let u16_at = |at: usize| usize::from(u16::from_le_bytes([bytes[at], bytes[at + 1]]));
777 let u32_at = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
778 let (mut entries, mut at) = (Vec::new(), 0);
779 while u32_at(at) == 0x0403_4b50 {
780 let (size, name_len) = (u32_at(at + 18) as usize, u16_at(at + 26));
781 let name = String::from_utf8(bytes[at + 30..at + 30 + name_len].to_vec()).unwrap();
782 let start = at + 30 + name_len;
783 let mut text = String::new();
784 flate2::read::DeflateDecoder::new(&bytes[start..start + size])
785 .read_to_string(&mut text)
786 .unwrap();
787 let mut crc = flate2::Crc::new();
788 crc.update(text.as_bytes());
789 assert_eq!(crc.sum(), u32_at(at + 14));
790 entries.push((name, text));
791 at = start + size;
792 }
793 assert_eq!(u32_at(at), 0x0201_4b50);
794 entries
795 }
796
797 #[test]
798 fn zip_contents() {
799 let dir = std::env::temp_dir().join(format!("yapi-bug-zip-{}", crate::time::uuid_v4()));
800 std::fs::create_dir_all(&dir).unwrap();
801 let declined = report(None, None);
802 assert_eq!(declined.file_name(), "yapi-bug-report-0190-id.zip");
803 let path = dir.join(declined.file_name());
804 declined.write_zip(&path).unwrap();
805 let entries = unzip(&std::fs::read(&path).unwrap());
806 let names: Vec<&str> = entries.iter().map(|(name, _)| name.as_str()).collect();
807 assert_eq!(names, ["report.json", "diagnostics.json"]);
808 assert!(entries[0].1.starts_with("{\n \"id\": \"0190-id\","));
809 let full = report(Some("{\"type\":\"session\"}\n"), Some("## What went wrong"));
810 full.write_zip(&path).unwrap();
811 let entries = unzip(&std::fs::read(&path).unwrap());
812 let names: Vec<&str> = entries.iter().map(|(name, _)| name.as_str()).collect();
813 assert_eq!(
814 names,
815 [
816 "report.json",
817 "diagnostics.json",
818 "session.jsonl",
819 "summary.md"
820 ]
821 );
822 assert_eq!(entries[3].1, "## What went wrong\n");
823 let _ = std::fs::remove_dir_all(&dir);
824 }
825
826 #[test]
827 fn issue_body_tilde_home() {
828 let report = report(
829 None,
830 Some("ENOENT: '/home/me/work/a.txt' in /home/me, not /home/meg or /home/me.d"),
831 );
832 let body = report.issue_body(Path::new("/home/me/"));
833 assert!(
834 body.contains("ENOENT: '~/work/a.txt' in ~, not /home/meg or /home/me.d"),
835 "{body}"
836 );
837 assert_eq!(tilde("/a/b", Path::new("/")), "/a/b");
838 }
839
840 #[test]
841 fn issue_body_omits_settings() {
842 let mut report = report(None, None);
843 report.metadata["settings"] = json!({
844 "global": {"shellCommandPrefix": "export GITHUB_TOKEN=ghp_secret", "apiKey": "sk-1"},
845 "project": {},
846 });
847 redact(&mut report.metadata);
848 let body = report.issue_body(Path::new("/home/me"));
849 assert!(body.contains("\"shellCommandPrefix\""), "{body}");
850 assert!(
851 !body.contains("ghp_secret") && !body.contains("<redacted>"),
852 "{body}"
853 );
854 let zipped = &report.files()[0].1;
855 assert!(
856 zipped.contains("export GITHUB_TOKEN=ghp_secret"),
857 "{zipped}"
858 );
859 assert!(zipped.contains("\"apiKey\": \"<redacted>\""), "{zipped}");
860 }
861
862 #[test]
863 fn issue_body_size_limit() {
864 let mut long = report(None, Some(&"s".repeat(BODY_LIMIT)));
865 long.metadata["settings"] = json!("r".repeat(BODY_LIMIT));
866 let body = long.issue_body(Path::new("/home/me"));
867 assert!(body.len() <= BODY_LIMIT, "{}", body.len());
868 assert!(body.ends_with("[truncated]\n```\n"));
869 assert_eq!(long.title(), "It broke");
870 let mut untitled = report(None, None);
871 untitled.metadata["hint"] = Value::Null;
872 assert_eq!(untitled.title(), "yapi bug report 0190-id");
873 }
874
875 fn faux_session(faux: &yapi_ai::faux::Faux) -> AgentSession {
877 let model: Model = serde_json::from_value(json!({
878 "id": "m", "name": "M", "api": "faux", "provider": "p", "baseUrl": "",
879 "reasoning": false, "input": ["text"],
880 "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
881 "contextWindow": 1000, "maxTokens": 100,
882 }))
883 .unwrap();
884 let mut registry = ModelRegistry::builtin();
885 registry.register_provider("p", vec![model.clone()]);
886 registry.set_runtime_key("p", "sk-SECRET".into());
887 let mut apis = yapi_ai::api::Apis::default();
888 apis.register(std::sync::Arc::new(faux.clone()));
889 let session = AgentSession::new(crate::agent_session::SessionConfig {
890 cwd: "/work".into(),
891 agent_dir: "/agent".into(),
892 settings: crate::settings::SettingsManager::in_memory(),
893 registry,
894 apis,
895 session: SessionManager::in_memory(Path::new("/work")),
896 model: Some(model),
897 thinking_level: yapi_types::message::ThinkingLevel::Off,
898 tools: Vec::new(),
899 extensions: Vec::new(),
900 include_extension_tools: false,
901 allowed_tools: None,
902 excluded_tools: Vec::new(),
903 resources: crate::agent_session::Resources::default(),
904 docs: crate::docs::Locations::default(),
905 });
906 let message = Message::User(yapi_types::message::UserMessage {
907 content: yapi_types::message::Content::Text("the agent hangs".into()),
908 timestamp: 0,
909 });
910 session
911 .with_session(|file| file.append_message(message))
912 .unwrap();
913 session
914 }
915
916 #[test]
917 fn cwd_and_transcript_optional() {
918 let session = faux_session(&yapi_ai::faux::Faux::default());
919 let declined = Report::collect(&session, " it hangs ", false, None);
920 assert_eq!(declined.metadata["hint"], "it hangs");
921 assert_eq!(declined.metadata["session"].get("cwd"), None);
922 assert_eq!(declined.metadata["model"]["id"], "m");
923 assert_eq!(
924 declined.metadata["provider"]["authStatus"]["source"],
925 "runtime"
926 );
927 assert!(declined.session.is_none());
928 assert!(!pretty(&declined.metadata).contains("sk-SECRET"));
929 let included = Report::collect(&session, "", true, None);
930 assert_eq!(included.metadata["hint"], Value::Null);
931 assert_eq!(included.metadata["session"]["cwd"], "/work");
932 assert!(included.session.unwrap().contains("the agent hangs"));
933 assert_eq!(included.diagnostics["assistantMessageCount"], 0);
934 }
935
936 #[tokio::test]
937 async fn summary_rejects_bad_output() {
938 use tokio_util::sync::CancellationToken;
939 use yapi_ai::faux::{Faux, Response};
940
941 let faux = Faux::new([
942 Response::text(" ## What went wrong\nIt hung. "),
943 Response::tool_call("t", "read", json!({})),
944 Response::text(" "),
945 ]);
946 let session = faux_session(&faux);
947 let summary = session.bug_report_summary(Some(" after /reload "), CancellationToken::new());
948 assert_eq!(summary.await.as_deref(), Ok("## What went wrong\nIt hung."));
949 let request = format!("{:?}", faux.requests()[0]);
950 assert!(request.contains("a bug report about yapi"), "{request}");
951 assert!(request.contains("home folder as ~/..."), "{request}");
952 assert!(request.contains("the agent hangs"));
953 assert!(request.contains("<user-report>\\nafter /reload\\n</user-report>"));
954 let tool = session
955 .bug_report_summary(None, CancellationToken::new())
956 .await;
957 assert_eq!(
958 tool,
959 Err("Bug report summary attempted to call a tool".to_owned())
960 );
961 let empty = session
962 .bug_report_summary(None, CancellationToken::new())
963 .await;
964 assert_eq!(empty, Err("Bug report summary was empty".to_owned()));
965 }
966
967 #[cfg(unix)]
970 #[test]
971 fn issue_create_avoids_shell() {
972 use std::os::unix::fs::PermissionsExt;
973 let dir = std::env::temp_dir().join(format!("yapi-bug-gh-{}", crate::time::uuid_v4()));
974 std::fs::create_dir_all(&dir).unwrap();
975 let gh = dir.join("gh");
976 let script = format!(
977 "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{dir}/args'\nwhile [ $# -gt 0 ]; do [ \"$1\" = --body-file ] && cp \"$2\" '{dir}/body'; shift; done\necho https://github.com/SkymanOne/yapi/issues/7\n",
978 dir = dir.display()
979 );
980 std::fs::write(&gh, script).unwrap();
981 std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).unwrap();
982 let report = report(Some("TRANSCRIPT-MARKER"), Some("SUMMARY-MARKER"));
983 let title = format!("$(touch {0}/pwned); `touch {0}/pwned` \"'", dir.display());
984 let body_file = dir.join("issue.md");
985 let body = report.issue_body(Path::new("/home/me"));
986 let url = create_issue(gh.as_os_str(), &title, &body, &body_file);
987 assert_eq!(
988 url.as_deref(),
989 Ok("https://github.com/SkymanOne/yapi/issues/7")
990 );
991 let args = std::fs::read_to_string(dir.join("args")).unwrap();
992 let expected = [
993 "issue",
994 "create",
995 "--repo",
996 "SkymanOne/yapi",
997 "--title",
998 title.as_str(),
999 "--body-file",
1000 ];
1001 assert_eq!(args.lines().take(7).collect::<Vec<_>>(), expected);
1002 assert_eq!(args.lines().nth(7), Some(body_file.to_str().unwrap()));
1003 assert!(!dir.join("pwned").exists());
1004 assert!(!body_file.exists());
1005 let body = std::fs::read_to_string(dir.join("body")).unwrap();
1006 assert!(body.starts_with("It broke\nafter /reload\n\n## Summary\n\nSUMMARY-MARKER"));
1007 assert!(body.contains("\"createdAt\": \"t\""));
1008 assert!(!body.contains("TRANSCRIPT-MARKER") && !body.contains("DIAGNOSTIC-MARKER"));
1009 let _ = std::fs::remove_dir_all(&dir);
1010 }
1011}