修复AGC模型请求与对话登录态自动续期
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m42s
Project CI / Native shell tests (pull_request) Successful in 19m29s

统一模型等API的401续期并同步Rust和Runner会话
补齐DirectProject鉴权失败续期与同回合重试
保留鉴权失败提示并阻止账号切换后的旧请求重发
补充续期成功失败并发及权限边界测试
同步客户端实施文档与认证排障记忆
This commit is contained in:
2026-09-11 16:31:14 +08:00
parent e15e3b455f
commit 77892a8930
6 changed files with 384 additions and 124 deletions
+47 -4
View File
@@ -245,6 +245,10 @@ import {
captureAgentRuntimeError,
invokeDiagnostic,
} from './services/errorReporting';
import {
currentPlatformSessionGeneration,
requestPlatformSessionRefresh,
} from './services/platformSession';
import type { HomeCreationType } from './view/home';
import {
type ProjectAgentResultSummary,
@@ -258,6 +262,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',
@@ -5605,10 +5638,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,
);
});
try {
await persistDirectAssistantMessage(reply);
} catch (error) {
@@ -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);
@@ -2200,116 +2200,155 @@ export function registerHomeProjectCreationTests() {
);
});
it('shows a readable reason when the direct Codex turn is rejected', async () => {
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project';
const manifest = createGameCreationAppManifest(
'failed-direct-project',
'直连失败项目',
);
const persistedMessages: Array<Record<string, unknown>> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
expect(args).toEqual({ projectPath });
return manifest;
it.each([false, true])(
'recovers a direct Codex auth failure or preserves its readable reason (refresh=%s)',
async (refreshSucceeds) => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
if (String(input) === '/api/auth/refresh') {
return new Response(
JSON.stringify({ token: 'refreshed-direct-token' }),
{
status: refreshSucceeds ? 200 : 401,
},
);
}
if (command === 'read_local_conversation') {
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
if (String(input) === '/api/auth/me') {
return new Response(JSON.stringify({ user: testAuthUser }));
}
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: { deniedCommands: [], confirmCommands: [] },
};
}
if (command === 'append_local_conversation_message') {
const message = args?.message as Record<string, unknown>;
const messageId = String(args?.messageId ?? '');
if (
!messageId ||
!persistedMessages.some(
(candidate) => candidate.messageId === messageId,
)
) {
persistedMessages.push({
schemaVersion: 'game-creator-conversation.v1',
...message,
messageId,
updatedAt: Number(
message.updatedAt ?? persistedMessages.length + 1,
),
});
throw new Error(`unexpected fetch ${String(input)}`);
});
let directAttempts = 0;
const expectedReply = refreshSucceeds
? '续期后对话完成'
: '陶泥儿智能创作 鉴权失败,请重新登录后重试';
const projectPath = `C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project-${refreshSucceeds}`;
const manifest = createGameCreationAppManifest(
'failed-direct-project',
'直连失败项目',
);
const persistedMessages: Array<Record<string, unknown>> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
expect(args).toEqual({ projectPath });
return manifest;
}
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'chat_with_game_creator_direct_codex') {
throw new Error('codex-app-server-error:unauthorized');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
if (command === 'read_local_conversation') {
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: { deniedCommands: [], confirmCommands: [] },
};
}
if (command === 'append_local_conversation_message') {
const message = args?.message as Record<string, unknown>;
const messageId = String(args?.messageId ?? '');
if (
!messageId ||
!persistedMessages.some(
(candidate) => candidate.messageId === messageId,
)
) {
persistedMessages.push({
schemaVersion: 'game-creator-conversation.v1',
...message,
messageId,
updatedAt: Number(
message.updatedAt ?? persistedMessages.length + 1,
),
});
}
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...persistedMessages],
};
}
if (command === 'chat_with_game_creator_direct_codex') {
directAttempts += 1;
if (refreshSucceeds && directAttempts === 2) {
expect(invoke).toHaveBeenCalledWith(
'install_platform_account_session',
expect.objectContaining({
userId: testAuthUser.id,
accessToken: 'refreshed-direct-token',
}),
);
return expectedReply;
}
throw new Error('codex-app-server-error:unauthorized');
}
if (
command === 'read_platform_account_session_generation' ||
command === 'install_platform_account_session' ||
command === 'clear_platform_account_session'
)
return null;
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
initialSupervisorMessage: '生成一个游戏',
}),
);
expect(
await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'),
).not.toBeNull();
await waitFor(() => {
expect(persistedMessages).toHaveLength(2);
});
expect(persistedMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: 'user', content: '生成一个游戏' }),
expect.objectContaining({
role: 'assistant',
content: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
initialSupervisorMessage: '生成一个游戏',
}),
]),
);
expect(JSON.stringify(persistedMessages)).not.toContain(
'codex-app-server-error:unauthorized',
);
cleanup();
);
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
}),
);
expect(await screen.findByText('生成一个游戏')).not.toBeNull();
expect(
await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'),
).not.toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(1);
});
expect(await screen.findByText(expectedReply)).not.toBeNull();
await waitFor(() => {
expect(persistedMessages).toHaveLength(2);
});
expect(persistedMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: 'user', content: '生成一个游戏' }),
expect.objectContaining({
role: 'assistant',
content: expectedReply,
}),
]),
);
expect(JSON.stringify(persistedMessages)).not.toContain(
'codex-app-server-error:unauthorized',
);
cleanup();
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
}),
);
expect(await screen.findByText('生成一个游戏')).not.toBeNull();
expect(await screen.findByText(expectedReply)).not.toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(refreshSucceeds ? 2 : 1);
if (refreshSucceeds) {
const attempts = invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
);
expect(attempts[1]?.[1]).toEqual(attempts[0]?.[1]);
}
},
);
}
export function registerRecentProjectsTests() {
@@ -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);
});
@@ -4984,6 +4984,11 @@
- 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。
- 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。
## AGC 登录态续期必须同步本地运行时
- 模型目录 HTTP 请求与 DirectProject 的 Rust/app-server 使用同一账号,但凭据分别保存在 WebView 与 Rust / Runner;续期应复用 `requestPlatformSessionRefresh` 完成用户核验及本地会话安装,不能只写 localStorage。
- 普通 API 仅在 `401` 时续期并至多重试一次;`403` 权限拒绝不重发。对话续期失败保留原机器可读鉴权错误,避免用户提示退化为普通执行失败;账号代次变化时停止旧请求。
## AGC 前端等待超时与 worker 端口冲突
- `backend` 模式需要同时探测 API、worker 和必要的 SpacetimeDB 端口。只让 API 漂移会遗漏仍被旧进程占用的 worker 端口。
@@ -1265,6 +1265,7 @@ game-project/
本文早期关于“DirectProject 关闭通用 shell、原生网络和主动工具”的描述属于迁移前基线,现由以下覆盖规则取代:DirectProject 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内恢复 Codex 原生文件/搜索/命令、图片查看和 Skill;其余 ToolHost/DirectHome 合同不变。客户端审核的 `agc_tools` MCP 继续承担平台美术、资源登记、去背景、浏览器试玩和受控搜索,并保留项目锁、幂等账本、下载校验、恢复与投影权威。
DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、完整插件 Runtime、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`:内置 `agc_tools` 由客户端启动参数注入,用户在客户端扩展列表启用的独立第三方 MCP 以原生配置写入该次隔离 home;全局 Codex MCP、禁用项、Plugin hooks/apps 和其它插件能力不进入 DirectProject。第三方项固定非 required,配置或启动失败只记录该项,不替换 `agc_tools`;provider session token、工具桥地址和受控搜索标记不得通过第三方 MCP 的环境转发字段泄露。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。
## 2026-08-24 AGC UI 原型桥接与自主 UI workflow
- 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。UI 编辑器独立的语义建议请求也必须复用统一 LLM 传输选择,`llm.stream=true` 时发送 `stream=true` 并聚合完整工具调用后再校验结果。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。
@@ -1287,3 +1288,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
- `/api/llm/responses` 与 `/api/llm/chat/completions` 的正式请求体上限为 `32 MiB`。两个路由必须显式配置 Axum `DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)`;不能依赖 handler 内的 `Bytes / Json` 后置检查,否则 Axum 默认 `2 MiB` 会先拒绝 Direct Codex 携带图片工具结果的大上下文请求。超过 `32 MiB` 仍返回 `413 PAYLOAD_TOO_LARGE`。
- Codex app-server 的 failed turn 需要把上游 / 连接层 HTTP 413、`PAYLOAD_TOO_LARGE` 和 provider proxy 的 `provider request too large` 映射为稳定分类 `codex-app-server-error:request-too-large`;用户可见文案固定为“模型请求体过大,请减少参考图或上下文后重试”,不得落入 `other` 或泛化成权限 / 安全策略错误。
## 2026-09-11 AGC 登录态自动续期
- AGC 前端请求客户端配套后端的鉴权 API(包括 `/api/llm/models`)收到 `401` 时,共享进行中的 refresh 请求;确认当前用户并安装 Rust / Runner 会话后,用新 access token 最多重试原请求一次。`403` 权限拒绝不触发续期;续期失败保留原鉴权错误,账号切换或登出后不重发旧请求。
- DirectProject 的 Rust/app-server 对话调用返回鉴权失效时,前端先刷新客户端平台会话并重新提交同一 `clientTurnId`;平台会话代次变化后由 app-server pool 使用新 access token 建立连接,避免长时间运行后必须重新登录。