修复AGC模型请求与对话登录态自动续期 (#329)
Project CI / Repository checks (push) Successful in 2m40s
Project CI / Frontend tests (push) Successful in 3m23s
Project CI / Backend tests (push) Successful in 5m46s
Project CI / Native shell tests (push) Successful in 18m30s

## 变更说明

- AGC 客户端配套后端 API 在 401 时自动刷新登录态并重试一次,覆盖模型目录请求。
- DirectProject 对话鉴权失效时刷新平台会话、同步 Rust/Runner 凭据并重试同一 clientTurnId。
- 403 权限拒绝不触发续期;账号切换后不重发旧请求。
- 补充并发续期、失败保留原错误、权限边界和 Direct 对话重试测试。

## 验证

- npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts apps/ai-game-creator-shell/tests/clientApi.test.ts apps/ai-game-creator-shell/tests/clientHttp.test.ts apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
- npm run agc:typecheck
- npm run check:encoding
- git diff --check

Reviewed-on: #329
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
This commit was merged in pull request #329.
This commit is contained in:
2026-09-11 18:09:48 +08:00
committed by 段舒康
parent 11c8cbdf67
commit fafe6b63cd
5 changed files with 240 additions and 20 deletions
+47 -4
View File
@@ -251,6 +251,10 @@ import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWo
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
import { captureAgentRuntimeError } from './services/errorReporting';
import {
currentPlatformSessionGeneration,
requestPlatformSessionRefresh,
} from './services/platformSession';
import type { HomeCreationType } from './view/home';
import {
type ProjectAgentResultSummary,
@@ -264,6 +268,35 @@ const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:';
const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX =
'direct-codex-turn-already-running:';
function isDirectCodexAuthenticationRequired(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('authentication-required') ||
message.includes('codex-app-server-error:unauthorized') ||
/kind=codex-app-server-unauthorized(?=\s|$)/.test(message) ||
message.includes('登录已失效')
);
}
async function withDirectCodexSessionRefresh<T>(operation: () => Promise<T>) {
const generation = currentPlatformSessionGeneration();
try {
return await operation();
} catch (error) {
if (!isDirectCodexAuthenticationRequired(error)) throw error;
if (currentPlatformSessionGeneration() !== generation) throw error;
const refresh = await requestPlatformSessionRefresh();
if (refresh.status === 'failed') throw error;
if (
refresh.status !== 'refreshed' ||
currentPlatformSessionGeneration() !== refresh.generation
) {
throw new Error('登录账号已变化,原对话请求已停止');
}
return operation();
}
}
const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([
'accepted',
'running',
@@ -5933,10 +5966,20 @@ export function App({
if (attachments?.length) {
directTurnInput.attachments = attachments;
}
const reply = await directInvoke<string>(
'chat_with_game_creator_direct_codex',
directTurnInput,
);
const reply = await withDirectCodexSessionRefresh(() => {
// 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。
activeDirectCodexTurnRef.current = {
projectPath: directProjectPath,
turnId: clientTurnId,
lastSequence: -1,
receivedDirectUpdate: false,
};
setDirectCodexStatus('accepted');
return directInvoke<string>(
'chat_with_game_creator_direct_codex',
directTurnInput,
);
});
// Rust already persisted the complete raw response items. Invalidate
// any history snapshot captured before the turn completed.
if (localProjectPathRef.current === directProjectPath) {
@@ -10,6 +10,10 @@ import {
} from '../../../../packages/shared/src';
import { fetchClientHttp } from './clientHttp';
import { captureClientError } from './errorReporting';
import {
currentPlatformSessionGeneration,
requestPlatformSessionRefresh,
} from './platformSession';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
@@ -84,23 +88,40 @@ export async function requestClientApi<T>(
fallbackMessage: string,
options: { skipAuth?: boolean } = {},
) {
const headers = new Headers(init.headers);
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
if (!options.skipAuth) {
const token = getStoredAuthAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
const generation = currentPlatformSessionGeneration();
const request = async () => {
const headers = new Headers(init.headers);
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
if (!options.skipAuth) {
const token = getStoredAuthAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
}
try {
return await fetchClientHttp(url, {
...init,
credentials: 'same-origin',
headers,
});
} catch (error) {
throw apiNetworkError(url, error);
}
};
let response = await request();
// Access tokens are short lived. Refresh the cookie-backed session once and
// retry the original request so callers do not need to handle token expiry.
if (!options.skipAuth && response.status === 401) {
if (currentPlatformSessionGeneration() === generation) {
const refresh = await requestPlatformSessionRefresh();
if (
refresh.status === 'refreshed' &&
currentPlatformSessionGeneration() === refresh.generation
) {
response = await request();
}
}
}
let response: Response;
try {
response = await fetchClientHttp(url, {
...init,
credentials: 'same-origin',
headers,
});
} catch (error) {
throw apiNetworkError(url, error);
}
if (!response.ok) {
captureApiErrorStatus(url, response);
@@ -0,0 +1,146 @@
/** @vitest-environment jsdom */
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import {
requestClientApi,
setStoredAuthAccessToken,
} from '../src/services/clientApi';
import {
beginPlatformSessionTransition,
commitAuthenticatedPlatformSession,
currentPlatformSessionGeneration,
resetPlatformSessionStateForTests,
} from '../src/services/platformSession';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
const user = { id: 'session-user' } as AuthUser;
const nativeInvoke = vi.fn(async () => null);
const catalog = { models: [{ id: 'quality', displayName: '高质量' }] };
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status });
beforeEach(async () => {
resetPlatformSessionStateForTests();
window.localStorage.clear();
nativeInvoke.mockClear();
window.__TAURI__ = { core: { invoke: nativeInvoke } };
setStoredAuthAccessToken('expired-token');
await commitAuthenticatedPlatformSession(
user,
currentPlatformSessionGeneration(),
);
nativeInvoke.mockClear();
});
afterEach(() => {
resetPlatformSessionStateForTests();
window.localStorage.clear();
delete window.__TAURI__;
vi.restoreAllMocks();
});
it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => {
let refreshCalls = 0;
let modelCalls = 0;
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input, init) => {
if (input === '/api/auth/refresh') {
refreshCalls += 1;
return json({ token: 'fresh-token' });
}
if (input === '/api/auth/me') return json({ user });
modelCalls += 1;
const token = new Headers(init?.headers).get('Authorization');
if (token === 'Bearer expired-token') return json({}, 401);
expect(token).toBe('Bearer fresh-token');
expect(nativeInvoke).toHaveBeenCalledWith(
'install_platform_account_session',
expect.objectContaining({
accessToken: 'fresh-token',
userId: user.id,
}),
);
return json(catalog);
});
const results = await Promise.all([
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
]);
expect(results).toEqual([catalog, catalog]);
expect(refreshCalls).toBe(1);
expect(modelCalls).toBe(4);
expect(fetch).toHaveBeenCalledTimes(6);
});
it.each([401])('续期失败保留原 HTTP %s,且不重发业务请求', async (status) => {
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(json({}, status))
.mockResolvedValueOnce(json({}, 401));
await expect(
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
).rejects.toMatchObject({ status });
expect(fetch).toHaveBeenCalledTimes(2);
});
it('跳过鉴权的请求不触发续期', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 401));
await expect(
requestClientApi('/api/example', {}, '读取失败', { skipAuth: true }),
).rejects.toMatchObject({ status: 401 });
expect(fetch).toHaveBeenCalledTimes(1);
});
it('403 权限拒绝不触发续期或重发写请求', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 403));
await expect(
requestClientApi(
'/api/example',
{ method: 'POST', body: '{}' },
'权限不足',
),
).rejects.toMatchObject({ status: 403 });
expect(fetch).toHaveBeenCalledTimes(1);
expect(nativeInvoke).not.toHaveBeenCalled();
});
it('续期成功后的再次未授权不循环重试', async () => {
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(json({}, 401))
.mockResolvedValueOnce(json({ token: 'fresh-token' }))
.mockResolvedValueOnce(json({ user }))
.mockResolvedValueOnce(json({}, 401));
await expect(
requestClientApi('/api/llm/models', {}, '读取失败'),
).rejects.toMatchObject({ status: 401 });
expect(fetch).toHaveBeenCalledTimes(4);
});
it('请求期间账号切换后,不替新账号续期或重发旧请求', async () => {
let finish!: (response: Response) => void;
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(
() =>
new Promise<Response>((resolve) => {
finish = resolve;
}),
);
const pending = requestClientApi('/api/llm/models', {}, '读取失败');
const rejection = expect(pending).rejects.toMatchObject({ status: 401 });
const generation = beginPlatformSessionTransition();
setStoredAuthAccessToken('other-token');
await commitAuthenticatedPlatformSession(
{ ...user, id: 'other-user' },
generation,
);
finish(json({}, 401));
await rejection;
expect(fetch).toHaveBeenCalledTimes(1);
});