From 5037089057e562d2faa793b9bbf8fc2dc3321ebe Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 6 Aug 2026 10:16:50 +0800 Subject: [PATCH] feat(chat): refresh agent credentials during tool calls --- contracts/agent-v1.openapi.json | 149 ++++++++++++++++++ contracts/manifest.json | 2 +- .../api/auth/[...nextauth]/options.test.ts | 59 +++++++ src/app/api/auth/[...nextauth]/options.ts | 9 +- .../useAgentChatSession.actions.test.tsx | 10 ++ .../useAgentChatSession.lifecycle.test.tsx | 10 ++ .../chat/hooks/useAgentChatSession.ts | 125 ++++++++++++++- src/generated/agentApi.ts | 128 +++++++++++++++ src/lib/chatStream.test.ts | 41 +++++ src/lib/chatStream.ts | 60 +++++++ 10 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 src/app/api/auth/[...nextauth]/options.test.ts diff --git a/contracts/agent-v1.openapi.json b/contracts/agent-v1.openapi.json index 0d76880..f4d1ffa 100644 --- a/contracts/agent-v1.openapi.json +++ b/contracts/agent-v1.openapi.json @@ -1386,6 +1386,155 @@ } } }, + "/api/v1/agent/sessions/{session_id}/credential-refreshes": { + "post": { + "operationId": "post_sessions_session_id_credential_refreshes", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Resume a waiting agent tool call with refreshed credentials", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "request_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "request_id" + ] + } + } + } + }, + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v1/agent/sessions/{session_id}/permission-responses": { "post": { "operationId": "post_sessions_session_id_permission_responses", diff --git a/contracts/manifest.json b/contracts/manifest.json index 464233d..9c66061 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "agent": { "file": "agent-v1.openapi.json", - "sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259" + "sha256": "d559c6e76c33e7a7451743f60d85da0630d0df14fb5215228acefcf2eaea555a" }, "server": { "file": "server-v1.openapi.json", diff --git a/src/app/api/auth/[...nextauth]/options.test.ts b/src/app/api/auth/[...nextauth]/options.test.ts new file mode 100644 index 0000000..82cfa14 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/options.test.ts @@ -0,0 +1,59 @@ +import authOptions from "./options"; + +describe("NextAuth access token refresh", () => { + it("forces a Keycloak refresh for an agent credential request", async () => { + const previousEnv = { + issuer: process.env.KEYCLOAK_ISSUER, + clientId: process.env.KEYCLOAK_CLIENT_ID, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, + }; + process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater"; + process.env.KEYCLOAK_CLIENT_ID = "frontend"; + process.env.KEYCLOAK_CLIENT_SECRET = "secret"; + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "fresh-access-token", + expires_in: 900, + refresh_token: "rotated-refresh-token", + }), + }); + globalThis.fetch = fetchMock as typeof fetch; + + try { + const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{ + accessToken?: string; + refreshToken?: string; + }>; + const result = await jwt({ + token: { + accessToken: "still-fresh-access-token", + accessTokenExpires: Date.now() + 600_000, + accessTokenIssuedAt: Date.now(), + refreshToken: "refresh-token", + }, + trigger: "update", + session: { forceRefresh: true }, + }); + + expect(result.accessToken).toBe("fresh-access-token"); + expect(result.refreshToken).toBe("rotated-refresh-token"); + expect(fetchMock).toHaveBeenCalledWith( + "https://keycloak.example/realms/tjwater/protocol/openid-connect/token", + expect.objectContaining({ method: "POST" }), + ); + } finally { + if (originalFetch) globalThis.fetch = originalFetch; + else delete (globalThis as { fetch?: typeof fetch }).fetch; + restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer); + restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId); + restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret); + } + }); +}); + +const restoreEnv = (key: string, value: string | undefined) => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +}; diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 97c98e0..dc1865a 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -80,7 +80,7 @@ const authOptions: NextAuthOptions = { ], secret: process.env.NEXTAUTH_SECRET, callbacks: { - jwt: async ({ token, profile, account }) => { + jwt: async ({ token, profile, account, trigger, session }) => { if (profile?.sub) { token.sub = profile.sub; } @@ -117,6 +117,13 @@ const authOptions: NextAuthOptions = { return { ...token, error: "SessionExpired" }; } + if ( + trigger === "update" && + (session as { forceRefresh?: unknown } | undefined)?.forceRefresh === true + ) { + return refreshAccessToken(token); + } + const accessTokenIsFresh = typeof token.accessTokenExpires === "number" && typeof token.accessTokenIssuedAt === "number" && diff --git a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx index d7a5145..77a14e7 100644 --- a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx @@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession"; import { abortAgentChat, forkAgentChat, + replyAgentCredentialRefresh, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, @@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream"; jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), forkAgentChat: jest.fn(async () => "forked-session"), + replyAgentCredentialRefresh: jest.fn(async () => undefined), replyAgentPermission: jest.fn(async () => undefined), replyAgentQuestion: jest.fn(async () => undefined), resumeAgentChatStream: jest.fn(async () => undefined), streamAgentChat: jest.fn(async () => undefined), })); +const mockUpdateSession = jest.fn(); + +jest.mock("next-auth/react", () => ({ + useSession: () => ({ update: mockUpdateSession }), +})); + const listChatSessions = jest.fn(); const deleteChatSession = jest.fn(); const updateChatSessionTitle = jest.fn(); @@ -51,12 +59,14 @@ describe("useAgentChatSession", () => { updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset(); + jest.mocked(replyAgentCredentialRefresh).mockReset(); jest.mocked(replyAgentPermission).mockReset(); jest.mocked(replyAgentQuestion).mockReset(); jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(streamAgentChat).mockReset(); jest.mocked(abortAgentChat).mockImplementation(async () => undefined); jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); + jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined); jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); diff --git a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx index b204bb2..5c8fabc 100644 --- a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx @@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession"; import { abortAgentChat, forkAgentChat, + replyAgentCredentialRefresh, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, @@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream"; jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), forkAgentChat: jest.fn(async () => "forked-session"), + replyAgentCredentialRefresh: jest.fn(async () => undefined), replyAgentPermission: jest.fn(async () => undefined), replyAgentQuestion: jest.fn(async () => undefined), resumeAgentChatStream: jest.fn(async () => undefined), streamAgentChat: jest.fn(async () => undefined), })); +const mockUpdateSession = jest.fn(); + +jest.mock("next-auth/react", () => ({ + useSession: () => ({ update: mockUpdateSession }), +})); + const listChatSessions = jest.fn(); const deleteChatSession = jest.fn(); const updateChatSessionTitle = jest.fn(); @@ -51,12 +59,14 @@ describe("useAgentChatSession", () => { updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset(); + jest.mocked(replyAgentCredentialRefresh).mockReset(); jest.mocked(replyAgentPermission).mockReset(); jest.mocked(replyAgentQuestion).mockReset(); jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(streamAgentChat).mockReset(); jest.mocked(abortAgentChat).mockImplementation(async () => undefined); jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); + jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined); jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 71a74e7..090a8d1 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -1,9 +1,11 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useSession } from "next-auth/react"; -import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream"; +import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentCredentialRefresh, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream"; import type { PermissionReply, StreamEvent } from "@/lib/chatStream"; +import { useAuthStore } from "@/store/authStore"; import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types"; import { cloneMessages } from "../globalChatboxUtils"; import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage"; @@ -80,6 +82,7 @@ export const useAgentChatSession = ({ getModel, getApprovalMode, }: UseAgentChatSessionOptions) => { + const { update: updateSession } = useSession(); const hydrationNonceRef = useRef(0); const [messages, setMessages] = useState([]); @@ -102,6 +105,7 @@ export const useAgentChatSession = ({ content: string; } | null>(null); const tokenPlaybackIntervalRef = useRef(null); + const credentialRefreshRequestIdsRef = useRef(new Set()); useEffect(() => { sessionIdRef.current = sessionId; @@ -289,6 +293,61 @@ export const useAgentChatSession = ({ return assistant?.id ?? fallback; }, []); + const handleCredentialRefresh = useCallback( + async (event: StreamEvent & { type: "credential_refresh_required" }) => { + if ( + !event.sessionId || + !event.requestId || + credentialRefreshRequestIdsRef.current.has(event.requestId) + ) { + return; + } + credentialRefreshRequestIdsRef.current.add(event.requestId); + try { + const refreshedSession = await updateSession({ forceRefresh: true }); + if ( + refreshedSession?.error || + typeof refreshedSession?.accessToken !== "string" || + !refreshedSession.accessToken + ) { + throw new Error("登录凭据续期失败"); + } + const authStore = useAuthStore.getState(); + authStore.setAccessToken(refreshedSession.accessToken); + authStore.clearSessionExpired(); + await replyAgentCredentialRefresh(event.sessionId, event.requestId); + } catch (error) { + useAuthStore.getState().markSessionExpired("refresh_failed"); + const assistantMessageId = getLastAssistantMessageId(); + if (assistantMessageId) { + const message = error instanceof Error ? error.message : String(error); + setMessages((prev) => + prev.map((item) => + item.id === assistantMessageId + ? { + ...item, + content: item.content || `⚠️ **${message}**`, + isError: true, + progress: upsertProgress(item.progress, { + type: "progress", + sessionId: event.sessionId, + id: `credential-refresh-${event.requestId}`, + phase: "credential_refresh", + status: "error", + title: "登录凭据续期失败", + detail: message, + }), + } + : item, + ), + ); + } + setIsStreaming(false); + } + }, + [getLastAssistantMessageId, updateSession], + ); + const applyStreamEvent = useCallback( ( event: StreamEvent, @@ -421,6 +480,68 @@ export const useAgentChatSession = ({ assistantMessageId, ), ); + } else if (event.type === "credential_refresh_required") { + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + progress: upsertProgress(message.progress, { + type: "progress", + sessionId: event.sessionId, + id: `credential-refresh-${event.requestId}`, + phase: "credential_refresh", + status: "running", + title: "正在续期登录凭据", + detail: `当前工具调用保持等待,最长 ${Math.ceil((event.timeoutMs ?? 30_000) / 1000)} 秒`, + startedAt: Date.now(), + }), + } + : message, + ), + ); + void handleCredentialRefresh(event); + } else if (event.type === "credential_refreshed") { + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + progress: upsertProgress(message.progress, { + type: "progress", + sessionId: event.sessionId, + id: `credential-refresh-${event.requestId}`, + phase: "credential_refresh", + status: "completed", + title: "登录凭据已续期", + }), + } + : message, + ), + ); + } else if (event.type === "credential_refresh_failed") { + useAuthStore.getState().markSessionExpired("refresh_failed"); + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + content: message.content || `⚠️ **${event.message}**`, + isError: true, + progress: upsertProgress(message.progress, { + type: "progress", + sessionId: event.sessionId, + id: `credential-refresh-${event.requestId}`, + phase: "credential_refresh", + status: "error", + title: "登录凭据续期失败", + detail: event.message, + }), + } + : message, + ), + ); + setIsStreaming(false); } else if (event.type === "done") { setMessages((prev) => prev.map((message) => { @@ -457,6 +578,7 @@ export const useAgentChatSession = ({ ); setIsStreaming(false); } else if (event.type === "auth_required") { + useAuthStore.getState().markSessionExpired("unauthorized"); setMessages((prev) => prev.map((message) => message.id === assistantMessageId @@ -477,6 +599,7 @@ export const useAgentChatSession = ({ appendArtifact, flushPendingTokens, getLastAssistantMessageId, + handleCredentialRefresh, onToolCall, queueTokenContent, ], diff --git a/src/generated/agentApi.ts b/src/generated/agentApi.ts index 2fd801e..bf44fc1 100644 --- a/src/generated/agentApi.ts +++ b/src/generated/agentApi.ts @@ -109,6 +109,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/agent/sessions/{session_id}/credential-refreshes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resume a waiting agent tool call with refreshed credentials */ + post: operations["post_sessions_session_id_credential_refreshes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/agent/sessions/{session_id}/permission-responses": { parameters: { query?: never; @@ -1195,6 +1212,117 @@ export interface operations { }; }; }; + post_sessions_session_id_credential_refreshes: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + request_id: string; + }; + }; + }; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Insufficient permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Resource conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Validation error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Upstream dependency error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Dependency unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; post_sessions_session_id_permission_responses: { parameters: { query?: never; diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index e8bada2..ab10db4 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -2,6 +2,7 @@ import { abortAgentChat, forkAgentChat, rejectAgentQuestion, + replyAgentCredentialRefresh, replyAgentPermission, replyAgentQuestion, type StreamEvent, @@ -171,6 +172,32 @@ describe("streamAgentChat", () => { }); }); + it("parses credential refresh lifecycle events", async () => { + mockNewSessionStream({ + ok: true, + body: makeStream([ + 'event: credential_refresh_required\ndata: {"session_id":"s1","request_id":"credential-1","reason":"access_token_rejected","timeout_ms":30000}\n\n', + 'event: credential_refreshed\ndata: {"session_id":"s1","request_id":"credential-1"}\n\n', + ]), + }); + const events: StreamEvent[] = []; + await streamAgentChat({ message: "hi", onEvent: (event) => events.push(event) }); + expect(events).toEqual([ + { + type: "credential_refresh_required", + sessionId: "s1", + requestId: "credential-1", + reason: "access_token_rejected", + timeoutMs: 30000, + }, + { + type: "credential_refreshed", + sessionId: "s1", + requestId: "credential-1", + }, + ]); + }); + it("parses tool_call arguments when params is empty", async () => { mockNewSessionStream({ ok: true, @@ -388,6 +415,20 @@ describe("streamAgentChat", () => { ); }); + it("submits refreshed credentials through the authenticated context", async () => { + apiFetch.mockResolvedValue({ ok: true, status: 202, text: async () => "" }); + await replyAgentCredentialRefresh("s1", "credential-1"); + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/sessions/s1/credential-refreshes"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ request_id: "credential-1" }), + projectHeaderMode: "include", + skipAuthRedirect: true, + }), + ); + }); + it("calls question reply and reject endpoints", async () => { apiFetch.mockResolvedValue({ ok: true, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index c2f98f0..8ba977d 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -90,6 +90,24 @@ export type StreamEvent = reason?: string; message: string; } + | { + type: "credential_refresh_required"; + sessionId: string; + requestId: string; + reason?: string; + timeoutMs?: number; + } + | { + type: "credential_refreshed"; + sessionId: string; + requestId: string; + } + | { + type: "credential_refresh_failed"; + sessionId: string; + requestId: string; + message: string; + } | { type: "tool_call"; sessionId: string; @@ -310,6 +328,7 @@ const emitParsedStreamEvent = ( message_id?: string; todos?: unknown; reason?: string; + timeout_ms?: number; }; if (event === "state") { onEvent({ @@ -366,6 +385,27 @@ const emitParsedStreamEvent = ( reason: parsed.reason, message: parsed.message ?? "登录态已过期,请刷新登录后重试", }); + } else if (event === "credential_refresh_required") { + onEvent({ + type: "credential_refresh_required", + sessionId: parsed.session_id ?? "", + requestId: parsed.request_id ?? "", + reason: parsed.reason, + timeoutMs: parsed.timeout_ms, + }); + } else if (event === "credential_refreshed") { + onEvent({ + type: "credential_refreshed", + sessionId: parsed.session_id ?? "", + requestId: parsed.request_id ?? "", + }); + } else if (event === "credential_refresh_failed") { + onEvent({ + type: "credential_refresh_failed", + sessionId: parsed.session_id ?? "", + requestId: parsed.request_id ?? "", + message: parsed.message ?? "登录凭据续期失败", + }); } else if (event === "tool_call") { onEvent({ type: "tool_call", @@ -637,6 +677,26 @@ export const replyAgentPermission = async ( } }; +export const replyAgentCredentialRefresh = async ( + sessionId: string, + requestId: string, +) => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/credential-refreshes`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request_id: requestId }), + projectHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `credential refresh reply failed: ${response.status}`); + } +}; + export const replyAgentQuestion = async ( sessionId: string, requestId: string,