feat(chat): refresh agent credentials during tool calls
This commit is contained in:
@@ -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": {
|
"/api/v1/agent/sessions/{session_id}/permission-responses": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_sessions_session_id_permission_responses",
|
"operationId": "post_sessions_session_id_permission_responses",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"agent": {
|
"agent": {
|
||||||
"file": "agent-v1.openapi.json",
|
"file": "agent-v1.openapi.json",
|
||||||
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
|
"sha256": "d559c6e76c33e7a7451743f60d85da0630d0df14fb5215228acefcf2eaea555a"
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -80,7 +80,7 @@ const authOptions: NextAuthOptions = {
|
|||||||
],
|
],
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
jwt: async ({ token, profile, account }) => {
|
jwt: async ({ token, profile, account, trigger, session }) => {
|
||||||
if (profile?.sub) {
|
if (profile?.sub) {
|
||||||
token.sub = profile.sub;
|
token.sub = profile.sub;
|
||||||
}
|
}
|
||||||
@@ -117,6 +117,13 @@ const authOptions: NextAuthOptions = {
|
|||||||
return { ...token, error: "SessionExpired" };
|
return { ...token, error: "SessionExpired" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trigger === "update" &&
|
||||||
|
(session as { forceRefresh?: unknown } | undefined)?.forceRefresh === true
|
||||||
|
) {
|
||||||
|
return refreshAccessToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
const accessTokenIsFresh =
|
const accessTokenIsFresh =
|
||||||
typeof token.accessTokenExpires === "number" &&
|
typeof token.accessTokenExpires === "number" &&
|
||||||
typeof token.accessTokenIssuedAt === "number" &&
|
typeof token.accessTokenIssuedAt === "number" &&
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
|
|||||||
import {
|
import {
|
||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
|
replyAgentCredentialRefresh,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
replyAgentQuestion,
|
replyAgentQuestion,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
|
|||||||
jest.mock("@/lib/chatStream", () => ({
|
jest.mock("@/lib/chatStream", () => ({
|
||||||
abortAgentChat: jest.fn(async () => undefined),
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
|
replyAgentCredentialRefresh: jest.fn(async () => undefined),
|
||||||
replyAgentPermission: jest.fn(async () => undefined),
|
replyAgentPermission: jest.fn(async () => undefined),
|
||||||
replyAgentQuestion: jest.fn(async () => undefined),
|
replyAgentQuestion: jest.fn(async () => undefined),
|
||||||
resumeAgentChatStream: jest.fn(async () => undefined),
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
streamAgentChat: 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 listChatSessions = jest.fn();
|
||||||
const deleteChatSession = jest.fn();
|
const deleteChatSession = jest.fn();
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
|
|||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
|
jest.mocked(replyAgentCredentialRefresh).mockReset();
|
||||||
jest.mocked(replyAgentPermission).mockReset();
|
jest.mocked(replyAgentPermission).mockReset();
|
||||||
jest.mocked(replyAgentQuestion).mockReset();
|
jest.mocked(replyAgentQuestion).mockReset();
|
||||||
jest.mocked(resumeAgentChatStream).mockReset();
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
jest.mocked(streamAgentChat).mockReset();
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||||
|
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
|
||||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||||
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
|
|||||||
import {
|
import {
|
||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
|
replyAgentCredentialRefresh,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
replyAgentQuestion,
|
replyAgentQuestion,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
|
|||||||
jest.mock("@/lib/chatStream", () => ({
|
jest.mock("@/lib/chatStream", () => ({
|
||||||
abortAgentChat: jest.fn(async () => undefined),
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
|
replyAgentCredentialRefresh: jest.fn(async () => undefined),
|
||||||
replyAgentPermission: jest.fn(async () => undefined),
|
replyAgentPermission: jest.fn(async () => undefined),
|
||||||
replyAgentQuestion: jest.fn(async () => undefined),
|
replyAgentQuestion: jest.fn(async () => undefined),
|
||||||
resumeAgentChatStream: jest.fn(async () => undefined),
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
streamAgentChat: 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 listChatSessions = jest.fn();
|
||||||
const deleteChatSession = jest.fn();
|
const deleteChatSession = jest.fn();
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
|
|||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
|
jest.mocked(replyAgentCredentialRefresh).mockReset();
|
||||||
jest.mocked(replyAgentPermission).mockReset();
|
jest.mocked(replyAgentPermission).mockReset();
|
||||||
jest.mocked(replyAgentQuestion).mockReset();
|
jest.mocked(replyAgentQuestion).mockReset();
|
||||||
jest.mocked(resumeAgentChatStream).mockReset();
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
jest.mocked(streamAgentChat).mockReset();
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||||
|
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
|
||||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||||
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
||||||
import { cloneMessages } from "../globalChatboxUtils";
|
import { cloneMessages } from "../globalChatboxUtils";
|
||||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
||||||
@@ -80,6 +82,7 @@ export const useAgentChatSession = ({
|
|||||||
getModel,
|
getModel,
|
||||||
getApprovalMode,
|
getApprovalMode,
|
||||||
}: UseAgentChatSessionOptions) => {
|
}: UseAgentChatSessionOptions) => {
|
||||||
|
const { update: updateSession } = useSession();
|
||||||
const hydrationNonceRef = useRef(0);
|
const hydrationNonceRef = useRef(0);
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
@@ -102,6 +105,7 @@ export const useAgentChatSession = ({
|
|||||||
content: string;
|
content: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
||||||
|
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionIdRef.current = sessionId;
|
sessionIdRef.current = sessionId;
|
||||||
@@ -289,6 +293,61 @@ export const useAgentChatSession = ({
|
|||||||
return assistant?.id ?? fallback;
|
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(
|
const applyStreamEvent = useCallback(
|
||||||
(
|
(
|
||||||
event: StreamEvent,
|
event: StreamEvent,
|
||||||
@@ -421,6 +480,68 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessageId,
|
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") {
|
} else if (event.type === "done") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) => {
|
prev.map((message) => {
|
||||||
@@ -457,6 +578,7 @@ export const useAgentChatSession = ({
|
|||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
} else if (event.type === "auth_required") {
|
} else if (event.type === "auth_required") {
|
||||||
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
message.id === assistantMessageId
|
message.id === assistantMessageId
|
||||||
@@ -477,6 +599,7 @@ export const useAgentChatSession = ({
|
|||||||
appendArtifact,
|
appendArtifact,
|
||||||
flushPendingTokens,
|
flushPendingTokens,
|
||||||
getLastAssistantMessageId,
|
getLastAssistantMessageId,
|
||||||
|
handleCredentialRefresh,
|
||||||
onToolCall,
|
onToolCall,
|
||||||
queueTokenContent,
|
queueTokenContent,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -109,6 +109,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/agent/sessions/{session_id}/permission-responses": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
post_sessions_session_id_permission_responses: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
rejectAgentQuestion,
|
rejectAgentQuestion,
|
||||||
|
replyAgentCredentialRefresh,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
replyAgentQuestion,
|
replyAgentQuestion,
|
||||||
type StreamEvent,
|
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 () => {
|
it("parses tool_call arguments when params is empty", async () => {
|
||||||
mockNewSessionStream({
|
mockNewSessionStream({
|
||||||
ok: true,
|
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 () => {
|
it("calls question reply and reject endpoints", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -90,6 +90,24 @@ export type StreamEvent =
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
message: 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";
|
type: "tool_call";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -310,6 +328,7 @@ const emitParsedStreamEvent = (
|
|||||||
message_id?: string;
|
message_id?: string;
|
||||||
todos?: unknown;
|
todos?: unknown;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
timeout_ms?: number;
|
||||||
};
|
};
|
||||||
if (event === "state") {
|
if (event === "state") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -366,6 +385,27 @@ const emitParsedStreamEvent = (
|
|||||||
reason: parsed.reason,
|
reason: parsed.reason,
|
||||||
message: parsed.message ?? "登录态已过期,请刷新登录后重试",
|
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") {
|
} else if (event === "tool_call") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "tool_call",
|
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 (
|
export const replyAgentQuestion = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user