feat(agents): add Codex app restart and CLI directory history

This commit is contained in:
lzt404 committed 2026-08-16 02:04:17 +08:00
1 parent 6dc6cb02e2
commit 0c83be3727
9 files changed
+486 -7

No files matched your search

+189 -2
View File
@@ -87,6 +87,39 @@ pub(crate) fn launch_agent(
}
}
#[tauri::command]
pub(crate) async fn restart_codex_app(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<(), String> {
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
let config = gui_config_state.snapshot()?;
let status = inspect_agent_config(
AgentClient::Codex,
&home,
config.port,
effective_agent_api_key(&config),
);
if !status.installed {
return Err("未检测到 Codex,请先安装并重新检测".to_string());
}
if status.oauth_configuration {
validate_codex_oauth_login(&home)?;
}
let target = find_codex_app_installation(&home)
.ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?;
tauri::async_runtime::spawn_blocking(move || {
stop_codex_desktop(&target)?;
launch_codex_target(&target)
})
.await
.map_err(|error| format!("重启 Codex App 任务失败: {error}"))?
}
fn validate_agent_launch_configuration(
client: AgentClient,
status: &AgentConfigStatus,
@@ -122,13 +155,152 @@ fn resolve_launch_directory(value: Option<&str>, fallback: &Path) -> Result<Path
fn launch_codex_desktop(home: &Path) -> Result<(), String> {
let target = find_codex_app_installation(home)
.ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?;
launch_codex_target(&target)
}
fn launch_codex_target(target: &CodexAppTarget) -> Result<(), String> {
match target {
#[cfg(target_os = "windows")]
CodexAppTarget::WindowsAppId(app_id) => launch_windows_store_app(&app_id, "Codex App"),
CodexAppTarget::Application(path) => launch_desktop_agent(&path, "Codex App"),
CodexAppTarget::WindowsAppId(app_id) => launch_windows_store_app(app_id, "Codex App"),
CodexAppTarget::Application(path) => launch_desktop_agent(path, "Codex App"),
}
}
#[cfg(target_os = "windows")]
fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> {
let script = windows_codex_stop_script(target);
let encoded = windows_powershell_encoded_command(&script);
let mut command = Command::new(windows_powershell_executable());
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded,
]);
configure_background_command(&mut command);
let output = command
.output()
.map_err(|error| format!("关闭 Codex App 失败: {error}"))?;
if output.status.success() {
return Ok(());
}
let detail = String::from_utf8_lossy(if output.stderr.is_empty() {
&output.stdout
} else {
&output.stderr
})
.trim()
.to_string();
Err(if detail.is_empty() {
"Codex App 未能完全关闭".to_string()
} else {
format!("关闭 Codex App 失败: {detail}")
})
}
#[cfg(target_os = "windows")]
fn windows_codex_stop_script(target: &CodexAppTarget) -> String {
let selector = match target {
CodexAppTarget::Application(path) => format!(
"$targetExecutable = {}\n$targetRoot = $null",
windows_powershell_single_quoted_literal(&path_to_string(path))
),
CodexAppTarget::WindowsAppId(app_id) => {
let package_family = app_id.split('!').next().unwrap_or(app_id);
format!(
concat!(
"$targetExecutable = $null\n",
"$packageFamily = {}\n",
"$package = @(Get-AppxPackage | Where-Object {{ $_.PackageFamilyName -eq $packageFamily }}) | Select-Object -First 1\n",
"$targetRoot = if ($package) {{ $package.InstallLocation }} else {{ $null }}\n",
"if (-not $targetRoot) {{ throw 'Codex App package directory was not found' }}"
),
windows_powershell_single_quoted_literal(package_family)
)
}
};
format!(
r#"$ErrorActionPreference = 'Stop'
{selector}
function Get-CodexAppProcesses {{
@(Get-CimInstance Win32_Process | Where-Object {{
$_.Name -in @('ChatGPT.exe', 'Codex.exe') -and
$_.ExecutablePath -and
(($targetExecutable -and [string]::Equals($_.ExecutablePath, $targetExecutable, [System.StringComparison]::OrdinalIgnoreCase)) -or
($targetRoot -and $_.ExecutablePath.StartsWith(($targetRoot.TrimEnd('\') + '\'), [System.StringComparison]::OrdinalIgnoreCase)))
}})
}}
$processes = @(Get-CodexAppProcesses)
foreach ($process in $processes) {{
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
}}
$deadline = [DateTime]::UtcNow.AddSeconds(5)
do {{
$remaining = @(Get-CodexAppProcesses)
if ($remaining.Count -eq 0) {{ exit 0 }}
Start-Sleep -Milliseconds 100
}} while ([DateTime]::UtcNow -lt $deadline)
throw "Codex App did not exit; remaining process IDs: $($remaining.ProcessId -join ', ')"
"#
)
}
#[cfg(target_os = "macos")]
fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> {
let CodexAppTarget::Application(executable) = target;
let process_name = executable
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.ok_or_else(|| "无法识别 Codex App 进程名称".to_string())?;
let application_name = executable
.ancestors()
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))
.and_then(Path::file_stem)
.and_then(|name| name.to_str())
.unwrap_or(process_name);
let escaped_name = application_name.replace('\\', "\\\\").replace('"', "\\\"");
let _ = Command::new("osascript")
.args([
"-e",
&format!("tell application \"{escaped_name}\" to quit"),
])
.status();
wait_for_unix_process_exit(process_name)
}
#[cfg(target_os = "macos")]
fn wait_for_unix_process_exit(process_name: &str) -> Result<(), String> {
for _ in 0..50 {
let status = Command::new("pgrep").args(["-x", process_name]).status();
if status.is_ok_and(|status| !status.success()) {
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
let _ = Command::new("pkill")
.args(["-KILL", "-x", process_name])
.status();
thread::sleep(Duration::from_millis(100));
let status = Command::new("pgrep")
.args(["-x", process_name])
.status()
.map_err(|error| format!("检查 Codex App 进程失败: {error}"))?;
if status.success() {
Err("Codex App 未能完全关闭".to_string())
} else {
Ok(())
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn stop_codex_desktop(_target: &CodexAppTarget) -> Result<(), String> {
Err("当前平台不支持重启 Codex App".to_string())
}
fn launch_claude_desktop(home: &Path) -> Result<(), String> {
if let Some(executable) = find_claude_desktop_executable(home) {
return launch_desktop_agent(&executable, "Claude Desktop");
@@ -394,4 +566,19 @@ mod tests {
assert_eq!(zcode[0].id, "app");
assert_eq!(kimi[0].id, "cli");
}
#[cfg(target_os = "windows")]
#[test]
fn codex_restart_script_is_scoped_to_the_detected_installation() {
let executable = CodexAppTarget::Application(PathBuf::from(r"C:\Apps\Codex\Codex.exe"));
let executable_script = windows_codex_stop_script(&executable);
assert!(executable_script.contains(r"C:\Apps\Codex\Codex.exe"));
assert!(executable_script.contains("Get-CimInstance Win32_Process"));
assert!(!executable_script.contains("taskkill"));
let store = CodexAppTarget::WindowsAppId("OpenAI.Codex_123!App".to_string());
let store_script = windows_codex_stop_script(&store);
assert!(store_script.contains("OpenAI.Codex_123"));
assert!(store_script.contains("PackageFamilyName"));
}
}
+1
View File
@@ -1985,6 +1985,7 @@ fn main() {
set_agent_config_enabled,
update_agent_config,
launch_agent,
restart_codex_app,
get_lan_ipv4,
save_gui_settings,
save_network_routing_settings,
+8 -1
View File
@@ -505,6 +505,8 @@ export const jaOverrides = {
'agents.launch.starting': '起動中',
'agents.launch.startCli': 'CLI を起動',
'agents.launch.startApp': 'App を起動',
'agents.launch.restartApp': 'App を再起動',
'agents.launch.restartingApp': '再起動中',
'agents.launch.start': '{target} を起動',
'agents.launch.unavailable': '起動できません',
'agents.launchDirectory.noDirectory': '未選択',
@@ -514,9 +516,14 @@ export const jaOverrides = {
'agents.launchDirectory.dialogTitle': '{client} の起動ディレクトリを選択',
'agents.launchDirectory.dialogDescription': 'プロジェクトの作業ディレクトリを選択してから、{client} を起動します。',
'agents.launchDirectory.workingDirectory': '作業ディレクトリ',
'agents.launchDirectory.browse': '参照',
'agents.launchDirectory.browse': '変更',
'agents.launchDirectory.directoryRequired': '先に作業ディレクトリを選択してください',
'agents.launchDirectory.launch': '{client} を起動',
'agents.launchDirectory.historyTitle': '最近使用した項目',
'agents.launchDirectory.lastUsed': '前回使用',
'agents.launchDirectory.recentItem': '履歴ディレクトリ',
'agents.launchDirectory.historyUse': '選択',
'agents.launchDirectory.historySelected': '選択済み',
'agents.status.detecting': '検出中',
'agents.status.unsupported': 'プラットフォーム非対応',
'agents.status.notInstalled': '未インストール',
+8 -1
View File
@@ -505,6 +505,8 @@ export const en: Record<MessageKey, string> = {
'agents.launch.starting': 'Starting',
'agents.launch.startCli': 'Start CLI',
'agents.launch.startApp': 'Start App',
'agents.launch.restartApp': 'Restart App',
'agents.launch.restartingApp': 'Restarting',
'agents.launch.start': 'Start {target}',
'agents.launch.unavailable': 'Cannot Start',
'agents.launchDirectory.noDirectory': 'Not selected',
@@ -514,9 +516,14 @@ export const en: Record<MessageKey, string> = {
'agents.launchDirectory.dialogTitle': 'Choose a Launch Directory for {client}',
'agents.launchDirectory.dialogDescription': 'Choose a project working directory, then start {client}.',
'agents.launchDirectory.workingDirectory': 'Working directory',
'agents.launchDirectory.browse': 'Browse',
'agents.launchDirectory.browse': 'Change',
'agents.launchDirectory.directoryRequired': 'Select a working directory first',
'agents.launchDirectory.launch': 'Start {client}',
'agents.launchDirectory.historyTitle': 'Recently Used',
'agents.launchDirectory.lastUsed': 'Last used',
'agents.launchDirectory.recentItem': 'Recent directory',
'agents.launchDirectory.historyUse': 'Select',
'agents.launchDirectory.historySelected': 'Selected',
'agents.status.detecting': 'Detecting',
'agents.status.unsupported': 'Unsupported Platform',
'agents.status.notInstalled': 'Not Installed',
+8 -1
View File
@@ -503,6 +503,8 @@ export const zhCN = {
'agents.launch.starting': '启动中',
'agents.launch.startCli': '启动 CLI',
'agents.launch.startApp': '启动 App',
'agents.launch.restartApp': '重启 App',
'agents.launch.restartingApp': '重启中',
'agents.launch.start': '启动 {target}',
'agents.launch.unavailable': '无法启动',
'agents.launchDirectory.noDirectory': '尚未选择',
@@ -512,9 +514,14 @@ export const zhCN = {
'agents.launchDirectory.dialogTitle': '选择 {client} 启动目录',
'agents.launchDirectory.dialogDescription': '选择项目工作目录,然后启动 {client}。',
'agents.launchDirectory.workingDirectory': '工作目录',
'agents.launchDirectory.browse': '浏览',
'agents.launchDirectory.browse': '修改',
'agents.launchDirectory.directoryRequired': '请先选择工作目录',
'agents.launchDirectory.launch': '启动 {client}',
'agents.launchDirectory.historyTitle': '最近使用',
'agents.launchDirectory.lastUsed': '上次使用',
'agents.launchDirectory.recentItem': '历史目录',
'agents.launchDirectory.historyUse': '选择',
'agents.launchDirectory.historySelected': '已选择',
'agents.status.detecting': '检测中',
'agents.status.unsupported': '平台不支持',
'agents.status.notInstalled': '未安装',
+109 -2
View File
@@ -51,6 +51,11 @@ import {
sameAgentModel,
sameAgentModelMappings,
} from '../services/agentConfigurationDraft';
import {
parseAgentLaunchDirectoryHistory,
rememberAgentLaunchDirectory,
type AgentLaunchDirectoryHistory,
} from '../services/agentLaunchDirectoryHistory';
import type { ModelOption } from '../services/modelService';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { CodexSessionsPanel } from './CodexSessionsPanel';
@@ -284,6 +289,7 @@ const DEFAULT_AGENT_SUBPAGE: AgentSubpageId = 'core';
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1';
const AGENT_LAUNCH_DIRECTORY_HISTORY_KEY = 'cpa-gui.agent-launch-directory-history.v1';
const readSelectedAgentClient = (): AgentClientId => {
const fallback = agentDefinitions[0].id;
@@ -334,6 +340,27 @@ const writeAgentModelSelections = (
}
};
const readAgentLaunchDirectoryHistory = (): AgentLaunchDirectoryHistory => {
if (typeof window === 'undefined') return {};
try {
return parseAgentLaunchDirectoryHistory(
window.localStorage.getItem(AGENT_LAUNCH_DIRECTORY_HISTORY_KEY),
agentDefinitions.map((agent) => agent.id),
);
} catch {
return {};
}
};
const writeAgentLaunchDirectoryHistory = (history: AgentLaunchDirectoryHistory) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(AGENT_LAUNCH_DIRECTORY_HISTORY_KEY, JSON.stringify(history));
} catch {
// Keep the current in-memory history when persistent storage is unavailable.
}
};
function AgentMark({ definition, size = 26 }: { definition: AgentDefinition; size?: number }) {
if (definition.icon) {
return <img src={definition.icon} alt="" className="provider-logo" />;
@@ -623,7 +650,7 @@ export function AgentsPage() {
const [loading, setLoading] = useState(true);
const [modelLoading, setModelLoading] = useState(false);
const [busyAction, setBusyAction] = useState<
'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | null
'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | null
>(null);
const busy = busyAction !== null;
const [detectionError, setDetectionError] = useState('');
@@ -640,6 +667,9 @@ export function AgentsPage() {
const [launchDirectory, setLaunchDirectory] = useState('');
const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState<AgentLaunchTarget | null>(null);
const [launchDirectoryError, setLaunchDirectoryError] = useState('');
const [launchDirectoryHistory, setLaunchDirectoryHistory] = useState(
readAgentLaunchDirectoryHistory,
);
const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState<OAuthLoginRequiredAction | null>(null);
const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState<boolean | null>(null);
const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState<PiProviderUpdateStatus | null>(null);
@@ -936,6 +966,7 @@ export function AgentsPage() {
: activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')),
);
const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target);
const activeLaunchDirectoryHistory = launchDirectoryHistory[selected] ?? [];
const modelHint = modelSelectionError
|| modelError
|| (modelLoading
@@ -1283,7 +1314,7 @@ export function AgentsPage() {
};
const openLaunchDirectoryDialog = (target: AgentLaunchTarget) => {
setLaunchDirectory('');
setLaunchDirectory(activeLaunchDirectoryHistory[0] ?? '');
setLaunchDirectoryTarget(target);
setLaunchDirectoryError('');
setLaunchDirectoryDialogOpen(true);
@@ -1307,6 +1338,14 @@ export function AgentsPage() {
}
};
const rememberLaunchDirectory = (client: AgentClientId, directory: string) => {
setLaunchDirectoryHistory((current) => {
const next = rememberAgentLaunchDirectory(current, client, directory);
writeAgentLaunchDirectoryHistory(next);
return next;
});
};
const invokeAgentLaunch = async (
target: AgentLaunchTarget,
workingDirectory: string | null = null,
@@ -1324,6 +1363,7 @@ export function AgentsPage() {
workingDirectory,
});
if (workingDirectory) {
rememberLaunchDirectory(selected, workingDirectory);
setLaunchDirectoryDialogOpen(false);
setLaunchDirectoryTarget(null);
}
@@ -1347,6 +1387,21 @@ export function AgentsPage() {
await invokeAgentLaunch(target);
};
const restartCodexApp = async () => {
setBusyAction('restart-app');
setLaunchError('');
try {
if (draftChanged) throw new Error(t('agents.error.applyFirst'));
await invoke('restart_codex_app');
} catch (requestError) {
if (!handleOAuthLoginError(requestError, 'launch')) {
setLaunchError(String(requestError));
}
} finally {
setBusyAction(null);
}
};
const launchFromDirectoryDialog = async () => {
if (!launchDirectoryTarget) return;
const workingDirectory = launchDirectory.trim();
@@ -1860,6 +1915,22 @@ export function AgentsPage() {
? t('agents.launch.starting')
: t('agents.launch.startApp')}
</button>
<button
type="button"
className="secondary-button agent-launch-button"
onClick={() => void restartCodexApp()}
disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged}
title={draftChanged
? t('agents.launch.applyFirst')
: appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'restart-app'
? <LoaderCircle size={16} className="spin" />
: <RefreshCw size={16} />}
{busyAction === 'restart-app'
? t('agents.launch.restartingApp')
: t('agents.launch.restartApp')}
</button>
</>
) : (
<button
@@ -1953,6 +2024,42 @@ export function AgentsPage() {
: t('agents.launchDirectory.browse')}</b>
</button>
</div>
{activeLaunchDirectoryHistory.length ? (
<div className="agent-launch-directory-history">
<strong>{t('agents.launchDirectory.historyTitle')}</strong>
<div className="agent-launch-directory-history-list">
{activeLaunchDirectoryHistory.map((directory, index) => {
const active = directory === launchDirectory;
return (
<button
type="button"
className={active ? 'active' : ''}
key={directory}
onClick={() => {
setLaunchDirectory(directory);
setLaunchDirectoryError('');
}}
disabled={busy}
title={directory}
>
<span>
<strong>{directory}</strong>
<small>{index === 0
? t('agents.launchDirectory.lastUsed')
: t('agents.launchDirectory.recentItem')}</small>
</span>
<b>
{active ? <Check size={15} aria-hidden /> : null}
{active
? t('agents.launchDirectory.historySelected')
: t('agents.launchDirectory.historyUse')}
</b>
</button>
);
})}
</div>
</div>
) : null}
{launchDirectoryError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{launchDirectoryError}
@@ -0,0 +1,50 @@
export type AgentLaunchDirectoryHistory = Record<string, string[]>;
export const MAX_AGENT_LAUNCH_DIRECTORY_HISTORY = 8;
const validDirectory = (value: unknown): value is string => (
typeof value === 'string'
&& Boolean(value.trim())
&& value.length <= 4096
&& !Array.from(value).some((character) => /[\u0000-\u001f\u007f]/u.test(character))
);
export const parseAgentLaunchDirectoryHistory = (
payload: string | null,
clients: readonly string[],
): AgentLaunchDirectoryHistory => {
if (!payload) return {};
try {
const parsed = JSON.parse(payload) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const source = parsed as Record<string, unknown>;
return clients.reduce<AgentLaunchDirectoryHistory>((history, client) => {
const entries = source[client];
if (!Array.isArray(entries)) return history;
const normalized = entries
.filter(validDirectory)
.map((directory) => directory.trim())
.filter((directory, index, directories) => directories.indexOf(directory) === index)
.slice(0, MAX_AGENT_LAUNCH_DIRECTORY_HISTORY);
if (normalized.length) history[client] = normalized;
return history;
}, {});
} catch {
return {};
}
};
export const rememberAgentLaunchDirectory = (
history: AgentLaunchDirectoryHistory,
client: string,
directory: string,
): AgentLaunchDirectoryHistory => {
const normalized = directory.trim();
if (!validDirectory(normalized)) return history;
const previous = history[client] ?? [];
return {
...history,
[client]: [normalized, ...previous.filter((item) => item !== normalized)]
.slice(0, MAX_AGENT_LAUNCH_DIRECTORY_HISTORY),
};
};
+79
View File
@@ -5631,6 +5631,85 @@ textarea:focus-visible {
font-size: var(--font-size-label);
}
.agent-launch-directory-history {
display: grid;
min-height: 0;
gap: 7px;
}
.agent-launch-directory-history > strong {
color: var(--theme-5f5a53);
font-size: var(--font-size-body);
}
.agent-launch-directory-history-list {
display: grid;
min-height: 64px;
max-height: 176px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
border: 1px solid var(--theme-d8d2c8);
border-radius: 9px;
background: var(--theme-fff);
}
.agent-launch-directory-history-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
min-height: 58px;
padding: 8px 11px;
border: 0;
border-bottom: 1px solid var(--theme-eeeae2);
border-radius: 0;
background: transparent;
color: var(--theme-2d2a26);
text-align: left;
}
.agent-launch-directory-history-list > button:last-child {
border-bottom: 0;
}
.agent-launch-directory-history-list > button:hover:not(:disabled) {
background: var(--theme-faf8f4);
}
.agent-launch-directory-history-list > button.active {
background: var(--theme-f1ecfb);
box-shadow: inset 3px 0 0 var(--theme-8069ad);
}
.agent-launch-directory-history-list > button > span {
display: grid;
min-width: 0;
gap: 3px;
}
.agent-launch-directory-history-list > button strong {
overflow: hidden;
font-size: var(--font-size-label);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-launch-directory-history-list > button small {
color: var(--theme-8a847c);
font-size: var(--font-size-meta);
}
.agent-launch-directory-history-list > button > b {
display: inline-flex;
flex: none;
align-items: center;
gap: 5px;
color: var(--theme-8069ad);
font-size: var(--font-size-label);
}
.agent-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'bun:test';
import {
MAX_AGENT_LAUNCH_DIRECTORY_HISTORY,
parseAgentLaunchDirectoryHistory,
rememberAgentLaunchDirectory,
} from '../src/services/agentLaunchDirectoryHistory';
describe('agent launch directory history', () => {
it('keeps only valid known-client entries', () => {
const history = parseAgentLaunchDirectoryHistory(JSON.stringify({
codex: [' C:\\work\\one ', '', 'C:\\work\\one', 42],
unknown: ['C:\\private'],
}), ['codex', 'claude-code']);
expect(history).toEqual({ codex: ['C:\\work\\one'] });
});
it('moves the successful directory to the front and limits history size', () => {
const existing = Array.from(
{ length: MAX_AGENT_LAUNCH_DIRECTORY_HISTORY },
(_, index) => `/work/${index}`,
);
const added = rememberAgentLaunchDirectory({ codex: existing }, 'codex', '/work/new');
expect(added.codex[0]).toBe('/work/new');
expect(added.codex).toHaveLength(MAX_AGENT_LAUNCH_DIRECTORY_HISTORY);
const reused = rememberAgentLaunchDirectory(added, 'codex', '/work/2');
expect(reused.codex[0]).toBe('/work/2');
expect(reused.codex.filter((directory) => directory === '/work/2')).toHaveLength(1);
});
it('falls back to an empty history for malformed storage', () => {
expect(parseAgentLaunchDirectoryHistory('{broken', ['codex'])).toEqual({});
});
});