合并 agent-mvp 到 master #1

Merged
jiang merged 282 commits from agent-mvp into master 2026-08-18 17:56:46 +08:00
10 changed files with 590 additions and 3 deletions
Showing only changes of commit 5037089057 - Show all commits
+149
View File
@@ -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",
+1 -1
View File
@@ -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;
};
+8 -1
View File
@@ -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,
], ],
+128
View File
@@ -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;
+41
View File
@@ -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,
+60
View File
@@ -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,