lingshu-core 0.10.0

Agent core: conversation loop, prompt builder, context compression, model routing
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
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
//! # E2E tests with VsCodeCopilot provider
//!
//! Validates that Lingshu works end-to-end using the VS Code Copilot
//! LLM provider with the live supported GPT-5 mini path. These tests require a running VS Code
//! instance with the Copilot extension and the `VSCODE_COPILOT_TOKEN`
//! environment variable (or a valid VS Code IPC socket) to be set.
//!
//! WHY `#[ignore]`: E2E tests against live providers incur cost and
//! latency and cannot run in CI without credentials. They are opted-in
//! with `cargo test -- --ignored` or by setting the env var sentinel.
//!
//! To run:
//! ```bash
//! cargo test -p lingshu-core --test e2e_copilot -- --include-ignored --nocapture
//! ```
//!
//! If a fresh GitHub device login is required, the test prints the official
//! authentication link and code to the terminal output.

use std::sync::Arc;

use lingshu_core::agent::{AgentBuilder, ApprovalChoice, StreamEvent};
use lingshu_tools::registry::ToolRegistry;
use edgequake_llm::providers::vscode::{auth::GitHubAuth, token::TokenManager};
use tempfile::TempDir;

const COPILOT_TEST_MODEL: &str = "gpt-5-mini";
const COPILOT_TEST_SPEC: &str = "vscode-copilot/gpt-5-mini";
const COPILOT_AUTO_MODEL: &str = "auto";
const COPILOT_AUTO_SPEC: &str = "vscode-copilot/auto";

/// Guard: skip test when VS Code Copilot is not available.
///
/// WHY env var check: We don't want to #[ignore] unconditionally
/// because that hides failures in environments where Copilot IS
/// available. Instead we check for the IPC socket path that
/// VS Code injects into every process it spawns. In CI without
/// VS Code, `VSCODE_IPC_HOOK_CLI` is absent and the test skips.
fn copilot_available() -> bool {
    // VS Code sets this env var in all child processes when the
    // Copilot extension is active.
    if std::env::var("VSCODE_IPC_HOOK_CLI").is_ok() || std::env::var("VSCODE_COPILOT_TOKEN").is_ok()
    {
        return true;
    }

    if let Some(home) = dirs::home_dir() {
        let candidates = [
            home.join(".config/github-copilot/hosts.json"),
            home.join("Library/Application Support/github-copilot/hosts.json"),
            home.join(".config/edgequake/copilot/github_token.json"),
            home.join("Library/Application Support/edgequake/copilot/github_token.json"),
        ];
        return candidates.iter().any(|path| path.exists());
    }

    false
}

fn is_verified_global_rate_limit(message: &str) -> bool {
    let msg = message.to_ascii_lowercase();
    msg.contains("user_weekly_rate_limited")
        || msg.contains("user_global_rate_limited")
        || msg.contains("global-chat:global-cogs-7-day-key")
}

fn needs_copilot_device_login(message: &str) -> bool {
    let msg = message.to_ascii_lowercase();
    msg.contains("no github copilot oauth session found")
        || msg.contains("run `lingshu auth login copilot`")
        || msg.contains("bad credentials")
        || msg.contains("rejected by github")
        || msg.contains("401 unauthorized")
}

#[test]
fn detects_when_e2e_should_trigger_device_login() {
    assert!(needs_copilot_device_login(
        "No GitHub Copilot OAuth session found. Run `lingshu auth login copilot`."
    ));
    assert!(needs_copilot_device_login(
        "All available GitHub Copilot credentials were rejected by GitHub"
    ));
    assert!(!needs_copilot_device_login(
        "user_weekly_rate_limited: retry after 1d 21h"
    ));
}

async fn ensure_copilot_authenticated_for_e2e() {
    let manager = TokenManager::new().expect("token manager should initialize");

    match manager.get_valid_copilot_token().await {
        Ok(_) => return,
        Err(err) => {
            let msg = err.to_string();
            if !needs_copilot_device_login(&msg) {
                eprintln!(
                    "Continuing Copilot E2E without interactive re-login; the remaining issue is not an auth prompt: {msg}"
                );
                return;
            }

            eprintln!(
                "Copilot E2E needs an official GitHub device login before the live request can continue: {msg}"
            );
        }
    }

    let auth = GitHubAuth::new().expect("GitHub auth client should initialize");
    let access_token = auth
        .device_code_flow(|code| {
            let url = code
                .verification_uri_complete
                .as_deref()
                .unwrap_or(&code.verification_uri);
            eprintln!("\nOpen this link to authenticate GitHub Copilot for the E2E test:\n{url}");
            eprintln!("Code: {}\n", code.user_code);
            eprintln!("Waiting for GitHub approval...\n");
        })
        .await
        .expect("device login should complete for the opted-in E2E run");

    manager
        .save_github_token(access_token)
        .await
        .expect("fresh GitHub token should be cached after device login");
    let _ = manager
        .get_valid_copilot_token()
        .await
        .expect("Copilot token refresh should succeed after device login");
}

/// Full chat round-trip: user → agent → LLM → response.
///
/// Uses the real supported GPT-5 mini Copilot model via the vscode-copilot provider.
#[tokio::test]
#[ignore = "requires VS Code Copilot (VSCODE_IPC_HOOK_CLI or VSCODE_COPILOT_TOKEN)"]
async fn e2e_agent_chat_with_copilot_gpt4_mini() {
    if !copilot_available() {
        eprintln!("Skipping: VS Code Copilot not available");
        return;
    }
    ensure_copilot_authenticated_for_e2e().await;

    let provider = lingshu_tools::create_provider_for_model("vscode-copilot", COPILOT_TEST_MODEL)
        .expect("should create VsCodeCopilot provider");

    let agent = AgentBuilder::new(COPILOT_TEST_SPEC)
        .provider(provider)
        .max_iterations(3)
        .build()
        .expect("agent build should succeed");

    match agent.chat("Reply with exactly: PONG").await {
        Ok(response) => {
            assert!(!response.is_empty(), "Response should not be empty");
            assert!(
                response.to_uppercase().contains("PONG"),
                "Expected PONG in response, got: {response}"
            );
        }
        Err(err) => {
            let msg = err.to_string();
            if is_verified_global_rate_limit(&msg) {
                eprintln!(
                    "Skipping live Copilot round-trip because GitHub auth succeeded but the account is currently under an upstream Copilot weekly/global rate limit: {msg}"
                );
                return;
            }
            panic!("chat should succeed: {msg}");
        }
    }
}

/// Full terminal E2E proof for Lingshu with VS Code Copilot using GPT-5 mini.
#[tokio::test]
#[ignore = "requires VS Code Copilot (VSCODE_IPC_HOOK_CLI or VSCODE_COPILOT_TOKEN)"]
async fn e2e_agent_chat_with_copilot_gpt5_mini() {
    if !copilot_available() {
        eprintln!("Skipping: VS Code Copilot not available");
        return;
    }
    ensure_copilot_authenticated_for_e2e().await;

    let provider = lingshu_tools::create_provider_for_model("vscode-copilot", COPILOT_TEST_MODEL)
        .expect("should create VsCodeCopilot provider");

    let agent = AgentBuilder::new(COPILOT_TEST_SPEC)
        .provider(provider)
        .max_iterations(3)
        .build()
        .expect("agent build should succeed");

    match agent
        .chat("Reply with exactly: EDGECRAB_GPT5_MINI_OK")
        .await
    {
        Ok(response) => {
            println!("GPT-5 mini response: {response}");
            assert!(!response.is_empty(), "Response should not be empty");
            assert!(
                response.to_uppercase().contains("EDGECRAB_GPT5_MINI_OK"),
                "Expected EDGECRAB_GPT5_MINI_OK in response, got: {response}"
            );
        }
        Err(err) => {
            let msg = err.to_string();
            if is_verified_global_rate_limit(&msg) {
                eprintln!(
                    "Skipping gpt-5-mini due to upstream Copilot weekly/global rate limit: {msg}"
                );
                return;
            }
            panic!("gpt-5-mini chat should succeed: {msg}");
        }
    }
}

/// Full terminal E2E proof for Lingshu with VS Code Copilot Auto mode.
#[tokio::test]
#[ignore = "requires VS Code Copilot (VSCODE_IPC_HOOK_CLI or VSCODE_COPILOT_TOKEN)"]
async fn e2e_agent_chat_with_copilot_auto() {
    if !copilot_available() {
        eprintln!("Skipping: VS Code Copilot not available");
        return;
    }
    ensure_copilot_authenticated_for_e2e().await;

    let provider = lingshu_tools::create_provider_for_model("vscode-copilot", COPILOT_AUTO_MODEL)
        .expect("should create VsCodeCopilot provider for Auto");

    let agent = AgentBuilder::new(COPILOT_AUTO_SPEC)
        .provider(provider)
        .max_iterations(3)
        .build()
        .expect("agent build should succeed");

    match agent.chat("Reply with exactly: EDGECRAB_AUTO_OK").await {
        Ok(response) => {
            println!("Auto response: {response}");
            assert!(!response.is_empty(), "Response should not be empty");
            assert!(
                response.to_uppercase().contains("EDGECRAB_AUTO_OK"),
                "Expected EDGECRAB_AUTO_OK in response, got: {response}"
            );
        }
        Err(err) => {
            let msg = err.to_string();
            if is_verified_global_rate_limit(&msg) {
                eprintln!(
                    "Skipping auto-mode due to upstream Copilot weekly/global rate limit: {msg}"
                );
                return;
            }
            panic!("auto-mode chat should succeed: {msg}");
        }
    }
}

/// Streaming round-trip: tokens arrive via channel.
#[tokio::test]
#[ignore = "requires VS Code Copilot (VSCODE_IPC_HOOK_CLI or VSCODE_COPILOT_TOKEN)"]
async fn e2e_agent_streaming_with_copilot() {
    if !copilot_available() {
        eprintln!("Skipping: VS Code Copilot not available");
        return;
    }
    ensure_copilot_authenticated_for_e2e().await;

    let provider = lingshu_tools::create_provider_for_model("vscode-copilot", COPILOT_TEST_MODEL)
        .expect("should create VsCodeCopilot provider");

    let agent = Arc::new(
        AgentBuilder::new(COPILOT_TEST_SPEC)
            .provider(provider)
            .max_iterations(2)
            .build()
            .expect("agent build should succeed"),
    );

    let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::unbounded_channel::<StreamEvent>();

    let agent_clone = Arc::clone(&agent);
    let handle = tokio::spawn(async move {
        match agent_clone
            .chat_streaming("Count from 1 to 3 with spaces.", chunk_tx)
            .await
        {
            Ok(()) => {}
            Err(err) => {
                let msg = err.to_string();
                if is_verified_global_rate_limit(&msg) {
                    eprintln!(
                        "Skipping streaming due to upstream Copilot weekly/global rate limit: {msg}"
                    );
                    return;
                }
                panic!("streaming should succeed: {msg}");
            }
        }
    });

    let mut accumulated = String::new();
    let mut got_done = false;

    while let Some(event) = chunk_rx.recv().await {
        match event {
            StreamEvent::Token(text) => accumulated.push_str(&text),
            StreamEvent::ToolGenerating { .. } => {} // tool drafting — ignore in this test
            StreamEvent::ToolExec { .. } => {} // tool execution events — just ignore in this test
            StreamEvent::ToolProgress { .. } => {} // tool progress events — just ignore in this test
            StreamEvent::ToolDone { .. } => {} // tool completion events — just ignore in this test
            StreamEvent::SubAgentStart { .. } => {} // delegated progress — not relevant here
            StreamEvent::SubAgentReasoning { .. } => {} // delegated progress — not relevant here
            StreamEvent::SubAgentToolExec { .. } => {} // delegated progress — not relevant here
            StreamEvent::SubAgentFinish { .. } => {} // delegated progress — not relevant here
            StreamEvent::RunFinished { .. } => {} // terminal outcome is surfaced separately from transport done
            StreamEvent::Done => {
                got_done = true;
                break;
            }
            StreamEvent::Clarify { .. } => {} // not expected in this test
            StreamEvent::Error(e) => {
                if is_verified_global_rate_limit(&e) {
                    eprintln!(
                        "Skipping streaming due to upstream Copilot weekly/global rate limit: {e}"
                    );
                    return;
                }
                panic!("Unexpected streaming error: {e}")
            }
            StreamEvent::Reasoning(_) => {} // extended thinking — ignore in this test
            StreamEvent::HookEvent { .. } => {} // hook events — not relevant in this test
            StreamEvent::ContextPressure { .. } => {} // pressure warnings — not relevant in this test
            // These interactive events are unexpected in an automated streaming test;
            // treat them as non-fatal by ignoring, preserving test determinism.
            StreamEvent::Approval { response_tx, .. } => {
                // Deny any unexpected approval request so the agent doesn't hang.
                let _ = response_tx.send(ApprovalChoice::Deny);
            }
            StreamEvent::SecretRequest { response_tx, .. } => {
                // Abort any unexpected secret request so the agent doesn't hang.
                let _ = response_tx.send(String::new());
            }
            StreamEvent::SteerPending { .. } => {} // steering notification — not relevant in this test
            StreamEvent::SteerApplied { .. } => {} // steering applied — not relevant in this test
            StreamEvent::ActivityNotice { .. } => {} // compression / process notices — ignore
            StreamEvent::LlmWaitProgress { .. } => {} // local LLM wait heartbeat — ignore
            StreamEvent::BackgroundProcessTail { .. } => {} // bg process tail — ignore
            StreamEvent::BackgroundProcessFinished { .. } => {} // bg process exit — ignore
            StreamEvent::ModelTransferComplete { .. } => {} // handoff — not relevant in this test
            StreamEvent::Footer(text) => accumulated.push_str(&text),
        }
    }

    handle.await.expect("spawn should complete");

    assert!(got_done, "Should receive Done event");
    assert!(!accumulated.is_empty(), "Should receive tokens");
    // Loose check: response mentions numbers 1–3
    let has_numbers = accumulated.contains('1') && accumulated.contains('3');
    assert!(has_numbers, "Expected 1..3 in response, got: {accumulated}");
}

/// Model hot-swap during session.
#[tokio::test]
#[ignore = "requires VS Code Copilot (VSCODE_IPC_HOOK_CLI or VSCODE_COPILOT_TOKEN)"]
async fn e2e_model_swap_with_copilot() {
    if !copilot_available() {
        eprintln!("Skipping: VS Code Copilot not available");
        return;
    }
    ensure_copilot_authenticated_for_e2e().await;

    let provider = lingshu_tools::create_provider_for_model("vscode-copilot", COPILOT_TEST_MODEL)
        .expect("copilot provider");

    let agent = AgentBuilder::new(COPILOT_TEST_SPEC)
        .provider(Arc::clone(&provider))
        .max_iterations(2)
        .build()
        .expect("build");

    // First turn
    let r1 = match agent.chat("Say ALPHA").await {
        Ok(res) => res,
        Err(err) => {
            let msg = err.to_string();
            if is_verified_global_rate_limit(&msg) {
                eprintln!(
                    "Skipping model swap due to upstream Copilot weekly/global rate limit: {msg}"
                );
                return;
            }
            panic!("first chat: {msg}");
        }
    };
    assert!(r1.to_uppercase().contains("ALPHA"), "got: {r1}");

    // Hot-swap to the same model (no-op but validates the plumbing)
    let provider2 = lingshu_tools::create_provider_for_model("vscode-copilot", COPILOT_TEST_MODEL)
        .expect("second provider");
    agent.swap_model(COPILOT_TEST_SPEC.into(), provider2).await;

    // Second turn after swap
    let r2 = match agent.chat("Say BETA").await {
        Ok(res) => res,
        Err(err) => {
            let msg = err.to_string();
            if is_verified_global_rate_limit(&msg) {
                eprintln!(
                    "Skipping model swap due to upstream Copilot weekly/global rate limit: {msg}"
                );
                return;
            }
            panic!("second chat: {msg}");
        }
    };
    assert!(r2.to_uppercase().contains("BETA"), "got: {r2}");
}

/// Live certification: Copilot drives `write_file`, turn-end footer surfaces ground truth.
#[tokio::test]
#[ignore = "requires VS Code Copilot (VSCODE_IPC_HOOK_CLI or VSCODE_COPILOT_TOKEN)"]
async fn e2e_file_mutation_verifier_footer_with_copilot() {
    if !copilot_available() {
        eprintln!("Skipping: VS Code Copilot not available");
        return;
    }
    ensure_copilot_authenticated_for_e2e().await;

    let workspace = TempDir::new().expect("temp workspace");
    let provider = lingshu_tools::create_provider_for_model("vscode-copilot", COPILOT_TEST_MODEL)
        .expect("should create VsCodeCopilot provider");

    let agent = AgentBuilder::new(COPILOT_TEST_SPEC)
        .provider(provider)
        .tools(Arc::new(ToolRegistry::new()))
        .max_iterations(12)
        .build()
        .expect("agent build should succeed");

    let prompt = "Use the write_file tool exactly once to create the file \
                  `mutation_e2e_probe.txt` with content `PROBE_LIVE` in the current working \
                  directory. Do not use any other tools. After write_file succeeds, reply with \
                  exactly: MUTATION_E2E_DONE";

    let result = match agent
        .run_conversation_in_cwd(prompt, None, None, workspace.path())
        .await
    {
        Ok(result) => result,
        Err(err) => {
            let msg = err.to_string();
            if is_verified_global_rate_limit(&msg) {
                eprintln!("Skipping live mutation verifier E2E (rate limit): {msg}");
                return;
            }
            panic!("conversation failed: {msg}");
        }
    };

    let probe = workspace.path().join("mutation_e2e_probe.txt");
    assert!(
        probe.is_file(),
        "write_file should create mutation_e2e_probe.txt in workspace"
    );
    assert_eq!(
        std::fs::read_to_string(&probe).expect("read probe"),
        "PROBE_LIVE"
    );

    let history_has_footer = result.messages.iter().any(|m| {
        m.text_content().contains("files-mutated")
            || m.text_content().contains("[file-mutation-verifier]")
    });
    assert!(
        result.final_response.contains("files-mutated") || history_has_footer,
        "expected mutation footer in final response or history; final_response={}",
        result.final_response
    );
    assert!(
        result.final_response.contains("MUTATION_E2E_DONE")
            || result.final_response.to_uppercase().contains("DONE"),
        "expected completion marker, got: {}",
        result.final_response
    );

    println!("Live mutation verifier E2E certified.");
    let tail = result.final_response.len().saturating_sub(500);
    println!("Response tail:\n{}", &result.final_response[tail..]);
}

/// Streaming path: `StreamEvent::Footer` is emitted before `Done`.
#[tokio::test]
#[ignore = "requires VS Code Copilot (VSCODE_IPC_HOOK_CLI or VSCODE_COPILOT_TOKEN)"]
async fn e2e_file_mutation_verifier_stream_footer_with_copilot() {
    if !copilot_available() {
        eprintln!("Skipping: VS Code Copilot not available");
        return;
    }
    ensure_copilot_authenticated_for_e2e().await;

    let workspace = TempDir::new().expect("temp workspace");
    let previous_cwd = std::env::current_dir().ok();
    std::env::set_current_dir(workspace.path()).expect("chdir to temp workspace");

    let provider = lingshu_tools::create_provider_for_model("vscode-copilot", COPILOT_TEST_MODEL)
        .expect("should create VsCodeCopilot provider");

    let agent = Arc::new(
        AgentBuilder::new(COPILOT_TEST_SPEC)
            .provider(provider)
            .tools(Arc::new(ToolRegistry::new()))
            .max_iterations(12)
            .build()
            .expect("agent build should succeed"),
    );

    let prompt = "Use write_file once to create `mutation_stream_probe.txt` with content `STREAM_PROBE`. \
                  Then reply exactly: STREAM_OK";

    let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::unbounded_channel::<StreamEvent>();
    let agent_clone = Arc::clone(&agent);
    let prompt = prompt.to_string();
    let handle = tokio::spawn(async move {
        match agent_clone.chat_streaming(&prompt, chunk_tx).await {
            Ok(()) => {}
            Err(err) => {
                let msg = err.to_string();
                if is_verified_global_rate_limit(&msg) {
                    eprintln!("Skipping stream mutation E2E (rate limit): {msg}");
                    return;
                }
                panic!("chat_streaming failed: {msg}");
            }
        }
    });

    let mut footer = String::new();
    let mut got_done = false;
    while let Some(event) = chunk_rx.recv().await {
        match event {
            StreamEvent::Footer(text) => footer = text,
            StreamEvent::Done => {
                got_done = true;
                break;
            }
            StreamEvent::Error(e) => {
                if is_verified_global_rate_limit(&e) {
                    eprintln!("Skipping stream mutation E2E (rate limit): {e}");
                    return;
                }
                panic!("stream error: {e}");
            }
            StreamEvent::Approval { response_tx, .. } => {
                let _ = response_tx.send(ApprovalChoice::Once);
            }
            StreamEvent::Clarify { response_tx, .. } => {
                let _ = response_tx.send("yes".into());
            }
            StreamEvent::SecretRequest { response_tx, .. } => {
                let _ = response_tx.send(String::new());
            }
            _ => {}
        }
    }
    handle.await.expect("join streaming task");

    assert!(got_done, "should receive Done");
    assert!(
        footer.contains("files-mutated"),
        "Footer event should carry success log, got: {footer}"
    );
    assert!(
        workspace.path().join("mutation_stream_probe.txt").is_file(),
        "stream path should still write the probe file"
    );
    println!("Stream Footer event:\n{footer}");

    if let Some(prev) = previous_cwd {
        let _ = std::env::set_current_dir(prev);
    }
}

/// Live certification: Copilot drives `write_file`; tool result carries LSP diagnostics.
#[tokio::test]
#[ignore = "requires VS Code Copilot (VSCODE_IPC_HOOK_CLI or VSCODE_COPILOT_TOKEN)"]
async fn e2e_lsp_write_diagnostics_with_copilot_gpt5_mini() {
    if !copilot_available() {
        eprintln!("Skipping: VS Code Copilot not available");
        return;
    }
    ensure_copilot_authenticated_for_e2e().await;

    let workspace = TempDir::new().expect("temp workspace");
    std::fs::create_dir_all(workspace.path().join("src")).expect("src dir");
    std::fs::write(
        workspace.path().join("Cargo.toml"),
        "[package]\nname = \"lsp_e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
    )
    .expect("cargo.toml");

    let provider = lingshu_tools::create_provider_for_model("vscode-copilot", COPILOT_TEST_MODEL)
        .expect("should create VsCodeCopilot provider");

    let agent = AgentBuilder::new(COPILOT_TEST_SPEC)
        .provider(provider)
        .tools(Arc::new(ToolRegistry::new()))
        .max_iterations(12)
        .build()
        .expect("agent build should succeed");

    let prompt = "Use the write_file tool exactly once to create `src/lsp_e2e_broken.rs` with this \
                  exact content (one line): fn main() { let _: i32 = \"not an int\"; }\n\
                  Do not use any other tools. After write_file succeeds, reply with exactly: \
                  LSP_E2E_DONE";

    let result = match agent
        .run_conversation_in_cwd(prompt, None, None, workspace.path())
        .await
    {
        Ok(result) => result,
        Err(err) => {
            let msg = err.to_string();
            if is_verified_global_rate_limit(&msg) {
                eprintln!("Skipping LSP write diagnostics E2E (rate limit): {msg}");
                return;
            }
            panic!("conversation failed: {msg}");
        }
    };

    let broken = workspace.path().join("src/lsp_e2e_broken.rs");
    assert!(
        broken.is_file(),
        "write_file should create src/lsp_e2e_broken.rs"
    );

    let tool_has_lsp = result.messages.iter().any(|m| {
        m.role == lingshu_types::Role::Tool
            && (m.text_content().contains("\"diagnostics\"")
                || m.text_content().contains("lsp_diagnostics")
                || m.text_content().contains("<diagnostics"))
    });

    assert!(
        tool_has_lsp,
        "write_file tool result should include LSP diagnostics when lsp.enabled; messages={:?}",
        result
            .messages
            .iter()
            .filter(|m| m.role == lingshu_types::Role::Tool)
            .map(|m| m.text_content())
            .collect::<Vec<_>>()
    );
    assert!(
        result.final_response.contains("LSP_E2E_DONE")
            || result.final_response.to_uppercase().contains("DONE"),
        "expected completion marker, got: {}",
        result.final_response
    );

    println!("Live LSP write diagnostics E2E certified with Copilot gpt-5-mini.");
}