diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 00000000..0b1db07e --- /dev/null +++ b/demo/README.md @@ -0,0 +1,25 @@ +# Original-app Claude reset demo + +Open `http://localhost:5179/demo/claude-reset.html#/quota` with Vite running on port 5179 +(`bun run dev -- --host 127.0.0.1 --port 5179`). + +This entry loads the real `src/main.tsx`, App, MainLayout and quota route. There is +no demo UI, toolbar or stylesheet. Four synthetic Claude accounts populate the +normal quota cards and timeline: + +- `alex.max`: usable grant; confirmation clears usage and spends one reset. +- `design.team`: exhausted grant. +- `sam.free`: ineligible account. +- `retry.max`: first claim has an unknown outcome; retry must reuse its request ID. + +Use the existing “储备重置次数” controls. Reload the page to reset all fixtures. +Other providers are intentionally empty. This is a quota-focused fixture, not a +complete mock of every route in the app; unsupported requests fail closed. + +Before any application import, the HTML replaces local/session storage with +memory-only stores and blocks fetch/XHR. CSP permits only the local Vite socket. +The entry installs an Axios adapter before loading the API client. All mock +credentials use `example.invalid`; no real management key is supplied. Both the +initial quota cache and refresh actions use the real quota parser and API modules. + +Regression check: `bun test demo/claude-reset-mocks.test.ts`. diff --git a/demo/claude-reset-mocks.test.ts b/demo/claude-reset-mocks.test.ts new file mode 100644 index 00000000..92543a37 --- /dev/null +++ b/demo/claude-reset-mocks.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; +import axios from 'axios'; +import { demoFiles, installDemoTransport } from './claude-reset-mocks'; + +describe('original-app demo transport', () => { + test('lists only synthetic accounts and rejects unimplemented requests', async () => { + const client = axios.create({ adapter: installDemoTransport() }); + expect((await client.get('/credentials')).data.files).toEqual(demoFiles); + await expect(client.get('https://example.invalid/real-api')).rejects.toThrow('blocked'); + await expect(client.delete('/credentials')).rejects.toThrow('blocked'); + }); + + test('unknown outcome requires the same request identity; refresh reflects a single spend', async () => { + const client = axios.create({ adapter: installDemoTransport() }); + const call = async (path: string, data?: string) => + ( + await client.post('/requests/api-call', { + authIndex: 'demo-ambiguous', + method: data ? 'POST' : 'GET', + url: `https://api.anthropic.com${path}`, + data, + }) + ).data.body; + const path = '/api/organizations/00000000-0000-4000-8000-000000000001/reset_rate_limits'; + const claim = JSON.stringify({ grant_id: 'demo_grant', request_id: 'same-request' }); + await expect(call(path, claim)).rejects.toThrow('unknown outcome'); + await expect( + call(path, JSON.stringify({ grant_id: 'demo_grant', request_id: 'different' })) + ).rejects.toThrow('identity changed'); + expect(await call(path, claim)).toEqual({ result: 'reset' }); + expect(await call(path, claim)).toEqual({ result: 'already_used' }); + const usage = await call('/api/oauth/usage'); + expect(usage.five_hour.utilization).toBe(0); + expect(usage.cedar_ember.grants[0].resets_left).toBe(1); + }); +}); diff --git a/demo/claude-reset-mocks.ts b/demo/claude-reset-mocks.ts new file mode 100644 index 00000000..bcd67b7e --- /dev/null +++ b/demo/claude-reset-mocks.ts @@ -0,0 +1,154 @@ +import type { AxiosAdapter } from 'axios'; +import type { AuthFileItem } from '@/types'; +import type { ApiCallRequest } from '@/services/api/apiCall'; + +type Scenario = 'available' | 'exhausted' | 'ineligible' | 'ambiguous'; +const accounts: { email: string; scenario: Scenario }[] = [ + { email: 'alex.max@example.invalid', scenario: 'available' }, + { email: 'design.team@example.invalid', scenario: 'exhausted' }, + { email: 'sam.free@example.invalid', scenario: 'ineligible' }, + { email: 'retry.max@example.invalid', scenario: 'ambiguous' }, +]; +export const demoFiles: AuthFileItem[] = accounts.map(({ email, scenario }, index) => ({ + name: `claude-${email}.json`, + email, + type: 'claude', + provider: 'claude', + authIndex: `demo-${scenario}`, + status: 'active', + disabled: false, + size: 1824 + index * 128, +})); +const isoAfter = (hours: number) => new Date(Date.now() + hours * 3600000).toISOString(); +const organization = '00000000-0000-4000-8000-000000000001'; + +/** In-memory, fail-closed Axios transport. No request is forwarded to an adapter. */ +export function installDemoTransport(): AxiosAdapter { + const states = new Map( + accounts.map(({ scenario }) => [ + `demo-${scenario}`, + { + scenario, + left: scenario === 'exhausted' ? 0 : 2, + reset: false, + pending: '', + completed: new Set(), + }, + ]) + ); + return async (config) => { + await new Promise((resolve) => setTimeout(resolve, 120)); + const method = config.method?.toUpperCase(); + const path = config.url; + let data: unknown; + if (method === 'GET' && path === '/config') { + data = {}; + } else if (method === 'GET' && path === '/credentials') { + data = { files: demoFiles, total: demoFiles.length }; + } else if (method === 'GET' && path === '/plugins') { + data = { plugins: [] }; + } else if (method === 'POST' && path === '/requests/api-call') { + const payload = ( + typeof config.data === 'string' ? JSON.parse(config.data) : config.data + ) as ApiCallRequest; + const state = states.get(payload.authIndex ?? ''); + if (!state) throw new Error('Demo: unknown account'); + const url = new URL(payload.url); + if (url.origin !== 'https://api.anthropic.com') throw new Error('Demo: blocked upstream'); + let body: unknown; + if (payload.method === 'GET' && url.pathname === '/api/oauth/profile') { + body = { + organization: { + uuid: organization, + organization_type: state.scenario === 'exhausted' ? 'claude_team' : 'claude_pro', + subscription_status: 'active', + }, + account: { has_claude_max: state.scenario !== 'ineligible', has_claude_pro: false }, + }; + } else if (payload.method === 'GET' && url.pathname === '/api/oauth/usage') { + body = { + five_hour: { + utilization: state.reset ? 0 : state.scenario === 'ineligible' ? 38 : 100, + resets_at: isoAfter(2.5), + }, + seven_day: { + utilization: state.reset ? 0 : state.scenario === 'exhausted' ? 96 : 72, + resets_at: isoAfter(96), + }, + seven_day_sonnet: { utilization: state.reset ? 0 : 43, resets_at: isoAfter(96) }, + extra_usage: { + is_enabled: state.scenario === 'available', + monthly_limit: 5000, + used_credits: 1250, + utilization: 25, + }, + cedar_ember: { + eligible: state.scenario !== 'ineligible', + ineligible_reason: state.scenario === 'ineligible' ? 'tier' : null, + next_grant_id: 'demo_grant', + at_limit: !state.reset && state.scenario !== 'ineligible', + grants: + state.scenario === 'ineligible' + ? [] + : [ + ...(state.scenario === 'available' + ? [ + { + id: 'a_backup', + label: '备用重置', + resets_total: 1, + resets_left: 1, + usable_now: true, + use_requires_limit: true, + }, + ] + : []), + { + id: 'demo_grant', + label: 'Claude Max · 每周储备重置', + resets_total: 3, + resets_left: state.left, + ends_at: isoAfter(168), + clears: ['five_hour', 'seven_day'], + usable_now: true, + use_requires_limit: true, + }, + ], + }, + }; + } else if ( + payload.method === 'POST' && + url.pathname === `/api/organizations/${organization}/reset_rate_limits` + ) { + const claim = JSON.parse(payload.data ?? '{}') as { + grant_id?: string; + request_id?: string; + }; + if (claim.grant_id !== 'demo_grant' || !claim.request_id) + throw new Error('Demo: invalid claim'); + if (state.completed.has(claim.request_id)) { + body = { result: 'already_used' }; + } else if (state.scenario === 'ineligible' || state.left <= 0 || state.reset) { + throw new Error('Demo: unavailable grant'); + } else { + if (state.scenario === 'ambiguous' && !state.pending) { + state.pending = claim.request_id; + throw new Error('Demo: simulated unknown outcome'); + } + if (state.pending && state.pending !== claim.request_id) + throw new Error('Demo: retry identity changed'); + state.completed.add(claim.request_id); + state.left -= 1; + state.reset = true; + body = { result: 'reset' }; + } + } else { + throw new Error(`Demo: unsupported upstream route ${payload.method} ${url.pathname}`); + } + data = { status_code: 200, header: {}, body }; + } else { + throw new Error(`Demo: blocked management route ${method} ${path}`); + } + return { data, status: 200, statusText: 'OK', headers: {}, config }; + }; +} diff --git a/demo/claude-reset.html b/demo/claude-reset.html new file mode 100644 index 00000000..3ea40c1c --- /dev/null +++ b/demo/claude-reset.html @@ -0,0 +1,43 @@ + + + + + + CLI Proxy API Management Center · Local Demo + + + + + + +
+ + + diff --git a/demo/claude-reset.tsx b/demo/claude-reset.tsx new file mode 100644 index 00000000..41260316 --- /dev/null +++ b/demo/claude-reset.tsx @@ -0,0 +1,47 @@ +// Install the transport before importing any application module (including apiClient). +import axios from 'axios'; +import { installDemoTransport, demoFiles } from './claude-reset-mocks'; + +axios.defaults.adapter = installDemoTransport(); + +async function start() { + const [ + { useAuthStore }, + { useThemeStore }, + { useLanguageStore }, + { useQuotaStore }, + { CLAUDE_CONFIG }, + { default: i18n }, + ] = await Promise.all([ + import('@/stores/useAuthStore'), + import('@/stores/useThemeStore'), + import('@/stores/useLanguageStore'), + import('@/stores/useQuotaStore'), + import('@/features/quota/providers/claude/data'), + import('@/i18n'), + ]); + useAuthStore.setState({ + isAuthenticated: true, + connectionStatus: 'connected', + apiBase: 'http://demo.invalid', + managementKey: '', + rememberPassword: false, + serverVersion: 'v8.0.0-demo', + supportsPlugin: false, + }); + useLanguageStore.getState().setLanguage('zh-CN'); + useThemeStore.getState().setTheme('light'); + // Populate the original quota cache through the same parser and mock transport + // used by the page's refresh actions, rather than replacing any UI component. + for (const file of demoFiles) { + const data = await CLAUDE_CONFIG.fetchQuota(file, i18n.t); + useQuotaStore.getState().setClaudeQuota((previous) => ({ + ...previous, + [file.name]: CLAUDE_CONFIG.buildSuccessState(data), + })); + } + if (!location.hash) location.hash = '/quota'; + await import('@/main'); +} + +void start(); diff --git a/src/features/quota/components/QuotaCard.tsx b/src/features/quota/components/QuotaCard.tsx index f59210be..b1814adb 100644 --- a/src/features/quota/components/QuotaCard.tsx +++ b/src/features/quota/components/QuotaCard.tsx @@ -22,7 +22,7 @@ import { import { bindQuotaClasses } from '../types'; import { QUOTA_ADAPTERS, type QuotaCardState } from '../providers'; import { isQuotaRefreshDisabled, type QuotaFileEntry } from '../logic'; -import { ClaudeResetGrants } from '../providers/claude/ClaudeResetGrants'; +import { useClaudeResetGrants } from '../providers/claude/ClaudeResetGrants'; import bodyStyles from './QuotaBody.module.scss'; import styles from './QuotaCard.module.scss'; @@ -66,6 +66,13 @@ export function QuotaCard(props: QuotaCardProps) { const status = quota?.status ?? 'idle'; const loading = status === 'loading'; + const claudeReset = useClaudeResetGrants( + file, + entry.type === 'claude' && status !== 'idle', + !canRefresh || loading || resetting, + quota, + onRefresh + ); const iconSrc = getAuthFileIcon(entry.type, resolvedTheme); const typeLabel = getTypeLabel(t, entry.type); const errorMessage = resolveQuotaErrorMessage( @@ -106,6 +113,21 @@ export function QuotaCard(props: QuotaCardProps) {
+ {entry.type === 'claude' && status === 'success' && ( + <> +
+ + {t('claude_reset.remaining')} + {claudeReset.count ?? '--'} + +
+ {claudeReset.message && ( +
+ {t(`claude_reset.${claudeReset.message}`)} +
+ )} + + )} {status === 'idle' ? ( )} {showReset && ( - setOpen(false)} - closeDisabled={busy} - footer={ - selected ? ( - - ) : undefined + session === apiClient.getConnectionRevision() && version === generation.current; + showConfirmation({ + title: t('claude_reset.title'), + message: t(pending ? 'claude_reset.retry_confirm' : 'claude_reset.confirm_text', { + name: file.name, + }), + confirmText: t(pending ? 'claude_reset.retry' : 'claude_reset.confirm'), + variant: 'primary', + onConfirm: async () => { + if (!current() || lock.current || useAuthStore.getState().connectionStatus !== 'connected') + return; + lock.current = true; + setBusy(true); + try { + const answer = await resetGrantOperations.run(key, authIndex, selected); + if (!current()) return; + showNotification( + t(`claude_reset.${answer.unresolved ? 'unknown' : answer.code}`), + !answer.unresolved && (answer.code === 'reset' || answer.code === 'already_used') + ? 'success' + : 'error' + ); + } catch { + if (!current()) return; + const unresolved = resetGrantOperations.inspect(key); + showNotification( + t(`claude_reset.${unresolved && !unresolved.code ? 'unknown' : 'blocked'}`), + 'error' + ); + } finally { + lock.current = false; + // A concurrent page-wide refresh can invalidate this read generation. + // Release the local lock regardless, but never refresh a replacement account. + setBusy(false); + setReload((value) => value + 1); + if (current()) onRefresh(); } - > -

{t('claude_reset.scope')}

-
- {busy &&

{t('claude_reset.loading')}

} - {message &&

{t(`claude_reset.${message}`)}

} -
- {pending ? ( - <> -

{t(expired ? 'claude_reset.expired' : 'claude_reset.unknown')}

- - - ) : ( - status && ( - <> - {!status.eligible &&

{t('claude_reset.ineligible')}

} - {status.grants.length === 0 &&

{t('claude_reset.empty')}

} - {status.grants.map((grant) => ( -
-

- {grant.label || grant.id} —{' '} - {t('claude_reset.count', { left: grant.resetsLeft, total: grant.resetsTotal })} -

- {grant.endsAt && ( -

- {t('claude_reset.ends', { date: new Date(grant.endsAt).toLocaleString() })} -

- )} - -
- ))} - - ) - )} - {selected && ( -

- {t('claude_reset.confirm_text', { - grant: status?.grants.find((grant) => grant.id === selected)?.label || selected, - })} -

- )} -
- - ); + }, + }); + }; + return { + count: status?.grants.reduce((sum, grant) => sum + grant.resetsLeft, 0) ?? null, + busy, + blocked, + confirm, + message: pending ? (expired ? 'expired' : 'unknown') : message, + buttonLabel: pending ? 'retry' : 'use', + }; } diff --git a/src/features/quota/providers/claude/selectResetGrant.ts b/src/features/quota/providers/claude/selectResetGrant.ts new file mode 100644 index 00000000..01a91297 --- /dev/null +++ b/src/features/quota/providers/claude/selectResetGrant.ts @@ -0,0 +1,19 @@ +import { + anthropicResetGrantBlocker, + type AnthropicResetGrantStatus, +} from '@/services/api/claudeResetGrants'; + +/** Prefer the upstream recommendation; otherwise use stable ID ordering. */ +export function selectResetGrant(status: AnthropicResetGrantStatus, now: number) { + if (status.cooldownUntil && Date.parse(status.cooldownUntil) > now) return undefined; + const usable = status.grants.filter( + (grant) => + !anthropicResetGrantBlocker(status, grant.id) && + (!grant.startsAt || Date.parse(grant.startsAt) <= now) && + (!grant.endsAt || Date.parse(grant.endsAt) > now) + ); + return ( + usable.find((grant) => grant.id === status.nextGrantId) ?? + usable.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0] + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cacfb461..f356196e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2201,6 +2201,8 @@ } }, "claude_reset": { + "retry_confirm": "Retry the unknown reset for “{{name}}” with its original request ID? A reset may already have been spent. Retry within 10 minutes; do not reload or start another claim in another tab.", + "remaining": "Resets remaining", "title": "Banked resets", "scope": "Uses a limited reset grant to clear eligible Claude usage windows. Only confirm when you want to spend a reset. Recovery is limited to this tab/session; do not reload or use another tab while an outcome is unknown.", "loading": "Loading…", @@ -2212,9 +2214,9 @@ "empty": "No banked reset grants.", "count": "{{left}} / {{total}} resets remaining", "ends": "Expires: {{date}}", - "use": "Use reset", + "use": "Reset limits", "confirm": "Confirm reset", - "confirm_text": "Spend one reset from “{{grant}}”? An unknown claim will be retried with its original request ID.", + "confirm_text": "Spend one reset to clear eligible usage limits for “{{name}}”? This uses a limited reset grant.", "reset": "Usage limits reset.", "already_used": "This grant was already used.", "not_limited": "No reset: the account is not currently limited.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2a570d2e..b420bf3e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2195,6 +2195,8 @@ } }, "claude_reset": { + "retry_confirm": "Повторить сброс «{{name}}» с неизвестным результатом и исходным ID запроса? Сброс мог быть потрачен. Повторите в течение 10 минут; не перезагружайте страницу и не создавайте запрос в другой вкладке.", + "remaining": "Осталось сбросов", "title": "Запас сбросов", "scope": "Использует ограниченное право на сброс лимитов Claude. Подтверждайте только если хотите потратить сброс. Восстановление доступно только в этой вкладке и сессии; при неизвестном результате не перезагружайте страницу и не используйте другую вкладку.", "loading": "Загрузка…", @@ -2206,9 +2208,9 @@ "empty": "Нет доступных запасных сбросов.", "count": "Осталось сбросов: {{left}} / {{total}}", "ends": "Истекает: {{date}}", - "use": "Использовать сброс", + "use": "Сбросить лимиты", "confirm": "Подтвердить сброс", - "confirm_text": "Потратить один сброс «{{grant}}»? Запрос с неизвестным результатом будет повторён с исходным ID.", + "confirm_text": "Потратить один сброс для снятия доступных лимитов «{{name}}»? Число сбросов ограничено.", "reset": "Лимиты использования сброшены.", "already_used": "Это право уже использовано.", "not_limited": "Сброс не выполнен: лимит ещё не достигнут.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f53942ec..fce60cbb 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2199,6 +2199,8 @@ } }, "claude_reset": { + "retry_confirm": "使用原请求 ID 重试“{{name}}”结果未知的重置?可能已消耗一次重置。请在 10 分钟内重试;请勿重新加载页面或在其他标签页发起新请求。", + "remaining": "剩余重置次数", "title": "储备重置次数", "scope": "使用有限的重置权益清除符合条件的 Claude 用量窗口。仅在确定要消耗一次重置时确认。恢复仅限当前标签页和会话;结果未知时,请勿刷新页面或使用其他标签页。", "loading": "加载中…", @@ -2210,9 +2212,9 @@ "empty": "没有储备重置权益。", "count": "剩余 {{left}} / {{total}} 次重置", "ends": "到期:{{date}}", - "use": "使用重置", + "use": "重置额度", "confirm": "确认重置", - "confirm_text": "消耗“{{grant}}”的一次重置?结果未知的请求将使用原请求 ID 重试。", + "confirm_text": "确定消耗一次重置,清除“{{name}}”符合条件的用量限制?重置次数有限。", "reset": "已重置用量限制。", "already_used": "该权益已使用。", "not_limited": "未重置:账户当前未达到限制。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5f80700d..cee344d6 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2225,6 +2225,8 @@ } }, "claude_reset": { + "retry_confirm": "使用原請求 ID 重試「{{name}}」結果未知的重置?可能已消耗一次重置。請在 10 分鐘內重試;請勿重新載入頁面或在其他分頁發起新請求。", + "remaining": "剩餘重置次數", "title": "儲備重置次數", "scope": "使用有限的重置權益清除符合條件的 Claude 用量視窗。僅在確定要消耗一次重置時確認。復原僅限目前分頁和工作階段;結果未知時,請勿重新載入頁面或使用其他分頁。", "loading": "載入中…", @@ -2236,9 +2238,9 @@ "empty": "沒有儲備重置權益。", "count": "剩餘 {{left}} / {{total}} 次重置", "ends": "到期:{{date}}", - "use": "使用重置", + "use": "重置額度", "confirm": "確認重置", - "confirm_text": "消耗「{{grant}}」的一次重置?結果未知的請求將使用原請求 ID 重試。", + "confirm_text": "確定消耗一次重置,清除「{{name}}」符合條件的用量限制?重置次數有限。", "reset": "已重置用量限制。", "already_used": "該權益已使用。", "not_limited": "未重置:帳戶目前未達到限制。", diff --git a/tests/claudeResetGrants.test.ts b/tests/claudeResetGrants.test.ts index 39029bff..2baf0502 100644 --- a/tests/claudeResetGrants.test.ts +++ b/tests/claudeResetGrants.test.ts @@ -1,3 +1,5 @@ +import { selectResetGrant } from '../src/features/quota/providers/claude/selectResetGrant'; + import { afterEach, describe, expect, test } from 'bun:test'; import { anthropicResetGrantBlocker, @@ -299,7 +301,9 @@ test('all grant messages and confirmation are translated in four locales', async ); for (const locale of locales) { expect(Object.keys(locale).sort()).toEqual(Object.keys(locales[0]).sort()); - expect(locale.confirm_text).toContain('{{grant}}'); + expect(locale.confirm_text).toContain('{{name}}'); + expect(locale.retry_confirm).toContain('{{name}}'); + expect(typeof locale.remaining).toBe('string'); expect(locale.count).toContain('{{left}}'); for (const key of [ ...ANTHROPIC_RESET_RESULTS, @@ -312,3 +316,39 @@ test('all grant messages and confirmation are translated in four locales', async } } }); + +test('card selection prefers usable recommendation and has deterministic fallback', () => { + const base = status(); + const a = { ...base.grants[0], id: 'a' }; + const b = { ...a, id: 'b' }; + const multiple = { ...base, grants: [b, a], nextGrantId: 'b' }; + expect(selectResetGrant(multiple, 0)?.id).toBe('b'); + expect(selectResetGrant({ ...multiple, nextGrantId: null }, 0)?.id).toBe('a'); + expect(selectResetGrant({ ...multiple, grants: [a, { ...b, paused: true }] }, 0)?.id).toBe('a'); + expect(selectResetGrant({ ...multiple, eligible: false }, 0)).toBeUndefined(); + expect(selectResetGrant({ ...multiple, atLimit: false }, 0)).toBeUndefined(); + expect( + selectResetGrant({ ...multiple, cooldownUntil: new Date(1000).toISOString() }, 0) + ).toBeUndefined(); + for (const changed of [ + { resetsLeft: 0 }, + { usableNow: false }, + { paused: true }, + { startsAt: new Date(1000).toISOString() }, + { endsAt: new Date(0).toISOString() }, + ]) { + expect(selectResetGrant({ ...base, grants: [{ ...a, ...changed }] }, 0)).toBeUndefined(); + } +}); + +test('Claude card uses Codex count and action styles and shared confirmation, not a grant dialog', async () => { + const card = await Bun.file('src/features/quota/components/QuotaCard.tsx').text(); + const hook = await Bun.file('src/features/quota/providers/claude/ClaudeResetGrants.tsx').text(); + expect(card).toContain('quotaClasses.codexPlanValue}>{claudeReset.count'); + expect(card).toContain('disabled={claudeReset.blocked}'); + expect(card).toContain('onClick={claudeReset.confirm}'); + expect(hook).toContain('showConfirmation({'); + expect(hook).toContain("pending ? 'claude_reset.retry_confirm'"); + expect(hook).not.toContain('