Merge branch 'main' into HEAD
14
README.en.md
@ -283,6 +283,20 @@ Thanks to the following open-source projects and community practices for referen
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
If this project helps you, please support it with a ⭐ Star so more people can discover Claude Code Haha.
|
||||
|
||||
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This repository is based on the Claude Code source leaked from the Anthropic npm registry on 2026-03-31. All original source code copyrights belong to [Anthropic](https://www.anthropic.com). It is provided for learning and research purposes only.
|
||||
|
||||
14
README.md
@ -283,6 +283,20 @@ http://127.0.0.1:2024
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star 趋势图
|
||||
|
||||
如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha!
|
||||
|
||||
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform="translate(512 512) scale(1.0612244898) translate(-512 -512)">
|
||||
<rect x="120" y="120" width="784" height="784" rx="180" fill="url(#panel)"/>
|
||||
<rect x="124" y="124" width="776" height="776" rx="176" stroke="url(#panelGlow)" stroke-width="8"/>
|
||||
<path d="M236 260C324 190 464 170 612 206" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
|
||||
@ -39,4 +40,5 @@
|
||||
<g filter="url(#sparkGlow)">
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#spark)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 809 B After Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.5 KiB |
@ -1,5 +1,6 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
fs,
|
||||
io::{Error as IoError, ErrorKind, Read, Write},
|
||||
net::{SocketAddr, TcpListener, TcpStream},
|
||||
path::PathBuf,
|
||||
@ -14,11 +15,11 @@ use std::{
|
||||
};
|
||||
|
||||
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::Emitter;
|
||||
use tauri::{AppHandle, Manager, RunEvent, State, WindowEvent};
|
||||
use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, RunEvent, State, WindowEvent};
|
||||
use tauri_plugin_shell::{
|
||||
process::{CommandChild, CommandEvent},
|
||||
ShellExt,
|
||||
@ -28,6 +29,10 @@ const SERVER_STARTUP_LOG_LIMIT: usize = 80;
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const TRAY_SHOW_ID: &str = "tray_show";
|
||||
const TRAY_QUIT_ID: &str = "tray_quit";
|
||||
const WINDOW_STATE_FILE: &str = "window-state.json";
|
||||
const MIN_WINDOW_WIDTH: u32 = 960;
|
||||
const MIN_WINDOW_HEIGHT: u32 = 640;
|
||||
const MIN_VISIBLE_PIXELS: i64 = 64;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ServerState(Mutex<ServerStatus>);
|
||||
@ -48,6 +53,15 @@ struct AppExitState {
|
||||
is_quitting: Mutex<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
struct StoredWindowState {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
maximized: bool,
|
||||
}
|
||||
|
||||
/// 与 ServerState 平级的 adapter 子进程状态。
|
||||
///
|
||||
/// adapter sidecar(claude-sidecar adapters --feishu --telegram)的生命周期
|
||||
@ -157,10 +171,187 @@ fn should_hide_to_tray(app: &AppHandle, label: &str) -> bool {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn is_persistable_window_state(state: &StoredWindowState) -> bool {
|
||||
state.width >= MIN_WINDOW_WIDTH && state.height >= MIN_WINDOW_HEIGHT
|
||||
}
|
||||
|
||||
fn has_meaningful_intersection(
|
||||
state: &StoredWindowState,
|
||||
monitor_x: i32,
|
||||
monitor_y: i32,
|
||||
monitor_width: u32,
|
||||
monitor_height: u32,
|
||||
) -> bool {
|
||||
let left = state.x as i64;
|
||||
let top = state.y as i64;
|
||||
let right = left + state.width as i64;
|
||||
let bottom = top + state.height as i64;
|
||||
|
||||
let monitor_left = monitor_x as i64;
|
||||
let monitor_top = monitor_y as i64;
|
||||
let monitor_right = monitor_left + monitor_width as i64;
|
||||
let monitor_bottom = monitor_top + monitor_height as i64;
|
||||
|
||||
right > monitor_left + MIN_VISIBLE_PIXELS
|
||||
&& bottom > monitor_top + MIN_VISIBLE_PIXELS
|
||||
&& left < monitor_right - MIN_VISIBLE_PIXELS
|
||||
&& top < monitor_bottom - MIN_VISIBLE_PIXELS
|
||||
}
|
||||
|
||||
fn is_window_state_visible_on_any_monitor(
|
||||
state: &StoredWindowState,
|
||||
monitors: &[tauri::Monitor],
|
||||
) -> bool {
|
||||
if monitors.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
monitors.iter().any(|monitor| {
|
||||
let position = monitor.position();
|
||||
let size = monitor.size();
|
||||
has_meaningful_intersection(state, position.x, position.y, size.width, size.height)
|
||||
})
|
||||
}
|
||||
|
||||
fn window_state_path(app: &AppHandle) -> Option<PathBuf> {
|
||||
match app.path().app_config_dir() {
|
||||
Ok(dir) => Some(dir.join(WINDOW_STATE_FILE)),
|
||||
Err(err) => {
|
||||
eprintln!("[desktop] failed to resolve app config dir: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stored_window_state(app: &AppHandle) -> Option<StoredWindowState> {
|
||||
let path = window_state_path(app)?;
|
||||
let data = match fs::read_to_string(&path) {
|
||||
Ok(data) => data,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => return None,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[desktop] failed to read window state {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::from_str::<StoredWindowState>(&data) {
|
||||
Ok(state) if is_persistable_window_state(&state) => Some(state),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[desktop] failed to parse window state {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_stored_window_state(app: &AppHandle, state: &StoredWindowState) {
|
||||
if !is_persistable_window_state(state) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(path) = window_state_path(app) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(err) = fs::create_dir_all(parent) {
|
||||
eprintln!(
|
||||
"[desktop] failed to create window state directory {}: {err}",
|
||||
parent.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let data = match serde_json::to_string_pretty(state) {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
eprintln!("[desktop] failed to serialize window state: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = fs::write(&path, data) {
|
||||
eprintln!(
|
||||
"[desktop] failed to write window state {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_window_state(window: &tauri::WebviewWindow) -> Option<StoredWindowState> {
|
||||
if window.is_minimized().unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let position = match window.outer_position() {
|
||||
Ok(position) => position,
|
||||
Err(err) => {
|
||||
eprintln!("[desktop] failed to read window position: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let size = match window.outer_size() {
|
||||
Ok(size) => size,
|
||||
Err(err) => {
|
||||
eprintln!("[desktop] failed to read window size: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let state = StoredWindowState {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
maximized: window.is_maximized().unwrap_or(false),
|
||||
};
|
||||
|
||||
is_persistable_window_state(&state).then_some(state)
|
||||
}
|
||||
|
||||
fn save_main_window_state(app: &AppHandle) {
|
||||
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = capture_window_state(&window) else {
|
||||
return;
|
||||
};
|
||||
|
||||
write_stored_window_state(app, &state);
|
||||
}
|
||||
|
||||
fn restore_main_window_state(app: &AppHandle) {
|
||||
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = read_stored_window_state(app) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let monitors = window.available_monitors().unwrap_or_default();
|
||||
if !is_window_state_visible_on_any_monitor(&state, &monitors) {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = window.unmaximize();
|
||||
let _ = window.set_size(PhysicalSize::new(state.width, state.height));
|
||||
let _ = window.set_position(PhysicalPosition::new(state.x, state.y));
|
||||
if state.maximized {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
@ -879,9 +1070,71 @@ fn kill_windows_sidecars() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode_terminal_output, default_utf8_locale, ensure_utf8_locale, parse_env_block};
|
||||
use super::{
|
||||
decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
|
||||
has_meaningful_intersection, is_persistable_window_state, parse_env_block,
|
||||
StoredWindowState,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn window_state_rejects_too_small_sizes() {
|
||||
let valid = StoredWindowState {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
maximized: false,
|
||||
};
|
||||
let too_narrow = StoredWindowState {
|
||||
width: 959,
|
||||
..valid.clone()
|
||||
};
|
||||
let too_short = StoredWindowState {
|
||||
height: 639,
|
||||
..valid.clone()
|
||||
};
|
||||
|
||||
assert!(is_persistable_window_state(&valid));
|
||||
assert!(!is_persistable_window_state(&too_narrow));
|
||||
assert!(!is_persistable_window_state(&too_short));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_state_requires_visible_monitor_intersection() {
|
||||
let state = StoredWindowState {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
maximized: false,
|
||||
};
|
||||
|
||||
assert!(has_meaningful_intersection(&state, 0, 0, 1920, 1080));
|
||||
assert!(!has_meaningful_intersection(
|
||||
&StoredWindowState {
|
||||
x: -1200,
|
||||
y: 100,
|
||||
..state.clone()
|
||||
},
|
||||
0,
|
||||
0,
|
||||
1920,
|
||||
1080,
|
||||
));
|
||||
assert!(!has_meaningful_intersection(
|
||||
&StoredWindowState {
|
||||
x: 1900,
|
||||
y: 100,
|
||||
..state
|
||||
},
|
||||
0,
|
||||
0,
|
||||
1920,
|
||||
1080,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_output_decoder_preserves_split_chinese_characters() {
|
||||
let mut pending = Vec::new();
|
||||
@ -1043,6 +1296,7 @@ pub fn run() {
|
||||
let app = builder
|
||||
.setup(|app| {
|
||||
setup_system_tray(app)?;
|
||||
restore_main_window_state(&app.handle());
|
||||
|
||||
let state = app.state::<ServerState>();
|
||||
let mut guard = state
|
||||
@ -1080,10 +1334,18 @@ pub fn run() {
|
||||
..
|
||||
} if should_hide_to_tray(app_handle, &label) => {
|
||||
api.prevent_close();
|
||||
save_main_window_state(app_handle);
|
||||
if let Some(window) = app_handle.get_webview_window(&label) {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
RunEvent::WindowEvent {
|
||||
label,
|
||||
event: WindowEvent::Moved(_) | WindowEvent::Resized(_),
|
||||
..
|
||||
} if label == MAIN_WINDOW_LABEL => {
|
||||
save_main_window_state(app_handle);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Reopen {
|
||||
has_visible_windows: false,
|
||||
@ -1093,11 +1355,13 @@ pub fn run() {
|
||||
}
|
||||
RunEvent::ExitRequested { .. } => {
|
||||
mark_app_quitting(app_handle);
|
||||
save_main_window_state(app_handle);
|
||||
stop_server_sidecar(app_handle);
|
||||
stop_adapters_sidecar(app_handle);
|
||||
}
|
||||
RunEvent::Exit => {
|
||||
mark_app_quitting(app_handle);
|
||||
save_main_window_state(app_handle);
|
||||
stop_server_sidecar(app_handle);
|
||||
stop_adapters_sidecar(app_handle);
|
||||
}
|
||||
|
||||
@ -859,6 +859,32 @@ describe('WebSocket Chat Integration', () => {
|
||||
expect(nextTurn.some((m) => m.type === 'message_complete')).toBe(true)
|
||||
})
|
||||
|
||||
it('should include desktop service diagnostics when CLI startup fails', async () => {
|
||||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-startup-missing-workdir-'))
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir }),
|
||||
})
|
||||
expect(createRes.status).toBe(201)
|
||||
const { sessionId } = await createRes.json() as { sessionId: string }
|
||||
|
||||
await fs.rm(workDir, { recursive: true, force: true })
|
||||
|
||||
const messages = await runTurn(sessionId, 'trigger startup diagnostics', true)
|
||||
const error = messages.find((msg) => msg.type === 'error')
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: 'WORKDIR_INVALID',
|
||||
})
|
||||
expect(error?.message).toContain('Desktop service diagnostics:')
|
||||
expect(error?.message).toContain(`sessionId: ${sessionId}`)
|
||||
expect(error?.message).toContain(`workDir: ${workDir}`)
|
||||
expect(error?.message).toContain('runtimeOverride: (none)')
|
||||
expect(error?.message).toContain('activeProviderId:')
|
||||
expect(error?.message).toContain('configuredProviders:')
|
||||
})
|
||||
|
||||
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
@ -1143,6 +1169,108 @@ describe('WebSocket Chat Integration', () => {
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('should ignore stale persisted runtime provider ids when resuming old sessions', async () => {
|
||||
const providerService = new ProviderService()
|
||||
const activeProvider = await providerService.addProvider({
|
||||
presetId: 'custom',
|
||||
name: 'Current Valid Provider',
|
||||
apiKey: 'key-current-valid',
|
||||
baseUrl: 'http://127.0.0.1:1/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
models: {
|
||||
main: 'current-main',
|
||||
haiku: 'current-haiku',
|
||||
sonnet: 'current-sonnet',
|
||||
opus: 'current-opus',
|
||||
},
|
||||
})
|
||||
await providerService.activateProvider(activeProvider.id)
|
||||
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir: process.cwd() }),
|
||||
})
|
||||
expect(createRes.status).toBe(201)
|
||||
const { sessionId } = await createRes.json() as { sessionId: string }
|
||||
|
||||
const staleProviderId = crypto.randomUUID()
|
||||
const originalStartSession = conversationService.startSession.bind(conversationService)
|
||||
const startCalls: Array<{
|
||||
sessionId: string
|
||||
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
|
||||
}> = []
|
||||
|
||||
conversationService.startSession = (async function patchedStartSession(
|
||||
sid: string,
|
||||
workDir: string,
|
||||
sdkUrl: string,
|
||||
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
|
||||
) {
|
||||
startCalls.push({ sessionId: sid, options })
|
||||
return originalStartSession(sid, workDir, sdkUrl, options)
|
||||
}) as typeof conversationService.startSession
|
||||
|
||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||
const messages: any[] = []
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
reject(new Error(`Timed out waiting for stale runtime resume for session ${sessionId}`))
|
||||
}, 10_000)
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string)
|
||||
messages.push(msg)
|
||||
|
||||
if (msg.type === 'connected') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'set_runtime_config',
|
||||
providerId: staleProviderId,
|
||||
modelId: 'stale-model',
|
||||
}))
|
||||
ws.send(JSON.stringify({ type: 'user_message', content: 'resume old session' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'error') {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
reject(new Error(msg.message))
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'message_complete') {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`WebSocket error for stale runtime resume session ${sessionId}`))
|
||||
}
|
||||
})
|
||||
|
||||
expect(startCalls).toHaveLength(1)
|
||||
expect(startCalls[0]).toMatchObject({
|
||||
sessionId,
|
||||
options: {
|
||||
providerId: activeProvider.id,
|
||||
},
|
||||
})
|
||||
expect(startCalls[0]?.options?.model).not.toBe('stale-model')
|
||||
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
|
||||
} finally {
|
||||
ws.close()
|
||||
conversationService.startSession = originalStartSession
|
||||
conversationService.stopSession(sessionId)
|
||||
await providerService.activateOfficial()
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('should resume streaming to a reconnected client during an active turn', async () => {
|
||||
await withMockStreamDelay(150, async () => {
|
||||
const sessionId = `chat-reconnect-${crypto.randomUUID()}`
|
||||
|
||||
@ -61,6 +61,7 @@ const runtimeOverrides = new Map<string, {
|
||||
|
||||
const runtimeTransitionPromises = new Map<string, Promise<void>>()
|
||||
const sessionStartupPromises = new Map<string, Promise<void>>()
|
||||
const lastResolvedStartupWorkDirs = new Map<string, string>()
|
||||
const prewarmPendingSessions = new Set<string>()
|
||||
const prewarmedSessions = new Set<string>()
|
||||
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
@ -265,7 +266,7 @@ async function handleUserMessage(
|
||||
console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`)
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: errMsg,
|
||||
message: await buildSessionStartupDiagnosticMessage(sessionId, errMsg),
|
||||
code,
|
||||
retryable:
|
||||
err instanceof ConversationStartupError ? err.retryable : false,
|
||||
@ -511,7 +512,10 @@ async function restartSessionWithPermissionMode(
|
||||
console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`)
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: `Failed to restart session with new permission mode: ${errMsg}`,
|
||||
message: await buildSessionStartupDiagnosticMessage(
|
||||
sessionId,
|
||||
`Failed to restart session with new permission mode: ${errMsg}`,
|
||||
),
|
||||
code: 'CLI_RESTART_FAILED',
|
||||
})
|
||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||
@ -545,7 +549,10 @@ async function restartSessionWithRuntimeConfig(
|
||||
console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`)
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: `Failed to switch provider/model: ${errMsg}`,
|
||||
message: await buildSessionStartupDiagnosticMessage(
|
||||
sessionId,
|
||||
`Failed to switch provider/model: ${errMsg}`,
|
||||
),
|
||||
code: 'CLI_RESTART_FAILED',
|
||||
})
|
||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||
@ -661,6 +668,7 @@ function cleanupSessionRuntimeState(sessionId: string) {
|
||||
runtimeOverrides.delete(sessionId)
|
||||
runtimeTransitionPromises.delete(sessionId)
|
||||
sessionStartupPromises.delete(sessionId)
|
||||
lastResolvedStartupWorkDirs.delete(sessionId)
|
||||
clearPrewarmState(sessionId)
|
||||
}
|
||||
|
||||
@ -758,6 +766,7 @@ async function ensureCliSessionStarted(
|
||||
|
||||
const startup = (async () => {
|
||||
const workDir = await resolveSessionWorkDir(sessionId)
|
||||
lastResolvedStartupWorkDirs.set(sessionId, workDir)
|
||||
const runtimeSettings = await getRuntimeSettings(sessionId)
|
||||
const sdkUrl =
|
||||
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
|
||||
@ -1223,14 +1232,28 @@ function rebindSessionOutput(
|
||||
})
|
||||
}
|
||||
|
||||
async function getRuntimeSettings(sessionId?: string): Promise<{
|
||||
type RuntimeSettings = {
|
||||
permissionMode?: string
|
||||
model?: string
|
||||
effort?: string
|
||||
providerId?: string | null
|
||||
}> {
|
||||
}
|
||||
|
||||
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
|
||||
const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined
|
||||
if (runtimeOverride) {
|
||||
if (typeof runtimeOverride.providerId === 'string') {
|
||||
const { providers } = await providerService.listProviders()
|
||||
const providerExists = providers.some((provider) => provider.id === runtimeOverride.providerId)
|
||||
if (!providerExists) {
|
||||
console.warn(
|
||||
`[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`,
|
||||
)
|
||||
runtimeOverrides.delete(sessionId!)
|
||||
return getDefaultRuntimeSettings()
|
||||
}
|
||||
}
|
||||
|
||||
const userSettings = await settingsService.getUserSettings()
|
||||
const effort =
|
||||
typeof userSettings.effort === 'string' && userSettings.effort.trim()
|
||||
@ -1245,10 +1268,21 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultRuntimeSettings()
|
||||
}
|
||||
|
||||
async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
|
||||
// Check if a custom provider is active
|
||||
const { activeId } = await providerService.listProviders()
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
let resolvedActiveId = activeId
|
||||
if (activeId && !providers.some((provider) => provider.id === activeId)) {
|
||||
console.warn(`[WS] Active provider id is stale, falling back to official provider: ${activeId}`)
|
||||
resolvedActiveId = null
|
||||
await providerService.activateOfficial()
|
||||
}
|
||||
|
||||
const userSettings = await settingsService.getUserSettings()
|
||||
const providerSettings = activeId
|
||||
const providerSettings = resolvedActiveId
|
||||
? await providerService.getManagedSettings()
|
||||
: undefined
|
||||
const modelSettings = providerSettings ?? userSettings
|
||||
@ -1262,7 +1296,7 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
|
||||
: undefined
|
||||
|
||||
let model: string | undefined
|
||||
if (activeId) {
|
||||
if (resolvedActiveId) {
|
||||
// Provider is active — only consult provider-managed cc-haha settings.
|
||||
// Global ~/.claude/settings.json model values must not bleed into provider mode.
|
||||
const baseModel =
|
||||
@ -1286,10 +1320,58 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
|
||||
permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
|
||||
model,
|
||||
effort,
|
||||
providerId: activeId,
|
||||
providerId: resolvedActiveId,
|
||||
}
|
||||
}
|
||||
|
||||
async function buildSessionStartupDiagnosticMessage(
|
||||
sessionId: string,
|
||||
cause: string,
|
||||
): Promise<string> {
|
||||
const lines = [
|
||||
cause,
|
||||
'',
|
||||
'Desktop service diagnostics:',
|
||||
`- sessionId: ${sessionId}`,
|
||||
]
|
||||
|
||||
try {
|
||||
const recentWorkDir = lastResolvedStartupWorkDirs.get(sessionId)
|
||||
const workDir =
|
||||
recentWorkDir ||
|
||||
conversationService.getSessionWorkDir(sessionId) ||
|
||||
await sessionService.getSessionWorkDir(sessionId)
|
||||
lines.push(`- workDir: ${workDir ?? '(unknown)'}`)
|
||||
} catch (err) {
|
||||
lines.push(`- workDir: failed to resolve (${err instanceof Error ? err.message : String(err)})`)
|
||||
}
|
||||
|
||||
const runtimeOverride = runtimeOverrides.get(sessionId)
|
||||
if (runtimeOverride) {
|
||||
lines.push(`- runtimeOverride.providerId: ${runtimeOverride.providerId ?? '(official)'}`)
|
||||
lines.push(`- runtimeOverride.modelId: ${runtimeOverride.modelId}`)
|
||||
} else {
|
||||
lines.push('- runtimeOverride: (none)')
|
||||
}
|
||||
|
||||
try {
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
lines.push(`- activeProviderId: ${activeId ?? '(official)'}`)
|
||||
lines.push(`- configuredProviders: ${providers.length}`)
|
||||
if (providers.length > 0) {
|
||||
lines.push(
|
||||
`- providerIndex: ${providers
|
||||
.map((provider) => `${provider.name} (${provider.id})`)
|
||||
.join(', ')}`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
lines.push(`- providers: failed to read (${err instanceof Error ? err.message : String(err)})`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function enqueueRuntimeTransition(
|
||||
sessionId: string,
|
||||
transition: () => Promise<void>,
|
||||
|
||||