feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
describe("authenticated session exports", () => {
it("fetches only Agent session files with identity and project headers", async () => {
const fetchMock = vi.fn(async () => new Response("ZIP", { headers: { "Content-Type": "application/zip" } }));
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("URL", class extends URL {
static createObjectURL = vi.fn(() => "blob:fixture");
static revokeObjectURL = vi.fn();
});
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
const client = createAgentApiClient("https://agent.test", { getAccessToken: async () => "FIXTURE", getProjectId: async () => "project-a" });
const path = "/api/v1/session/session-a/files/mcp-11111111-1111-1111-1111-111111111111.zip";
await client.downloadFile(path);
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`https://agent.test${path}`);
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer FIXTURE");
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project-a");
expect(init.redirect).toBe("error");
expect(click).toHaveBeenCalledOnce();
});
it("rejects arbitrary URLs and paths before requesting any credential", async () => {
const getAccessToken = vi.fn();
const client = createAgentApiClient("https://agent.test", { getAccessToken });
for (const path of ["https://foreign.test/file.zip", "/api/v1/admin/files/a.zip", "/api/v1/session/s/files/secret.env", "/api/v1/session/s/files/../owner.json"]) {
await expect(client.downloadFile(path)).rejects.toThrow("无效");
}
expect(getAccessToken).not.toHaveBeenCalled();
});
});
+34 -3
View File
@@ -2,16 +2,45 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
describe("Agent prompt suggestions", () => {
it("uses owned topic endpoints and retains full-text hits from another topic", async () => {
const topic = { id: "topic-a", name: "城南压力问题", createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" };
const fetchMock = vi.fn()
.mockResolvedValueOnce(Response.json({ topics: [topic] }))
.mockResolvedValueOnce(Response.json({ sessions: [{ id: "session-b", title: "另一段对话", topic_id: "topic-b", topic_name: "检修事件", search_snippet: "正文中的南桥阀门", purpose: "task_collaboration" }], has_more: false }))
.mockResolvedValueOnce(Response.json({ session_id: "session-b", topic_id: "topic-a" }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project-a" });
expect(await client.listTopics()).toEqual([topic]);
const result = await client.listSessionPage({ q: "南桥阀门", includeCollaboration: true });
expect(result.sessions[0]).toMatchObject({ title: "另一段对话", topicId: "topic-b", topicName: "检修事件", searchSnippet: "正文中的南桥阀门", purpose: "task_collaboration" });
const query = new URL(String(fetchMock.mock.calls[1]?.[0])).searchParams;
expect(query.get("q")).toBe("南桥阀门");
expect(query.has("topic_id")).toBe(false);
expect(query.get("include_collaboration")).toBe("true");
await client.moveSessionToTopic("session-b", "topic-a");
expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({ topic_id: "topic-a" });
await client.deleteTopic("topic-a");
expect(String(fetchMock.mock.calls[3]?.[0])).toContain("/topics/topic-a");
for (const [, init] of fetchMock.mock.calls) {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token");
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("project-a");
}
});
it("submits and edits pending input through the scoped Responses and session endpoints", async () => {
const input = { id: `msg_${"1".repeat(32)}`, text: "补充压力测点", delivery: "steer", status: "pending" };
const fetchMock = vi.fn().mockResolvedValueOnce(Response.json(input)).mockResolvedValueOnce(Response.json({ items: [input] }))
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }));
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }))
.mockResolvedValueOnce(Response.json({ ...input, status: "cancelled" }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
await expect(client.submitResponseInput("session-1", input.text, "steer", input.id)).resolves.toEqual(input);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ input: input.text, conversation: "session-1", stream: false, tjwater: { delivery: "steer", input_id: input.id } });
await expect(client.listResponseInputs("session-1")).resolves.toEqual([input]);
await expect(client.updateResponseInput("session-1", input.id, "queue")).resolves.toEqual({ ...input, delivery: "queue" });
await expect(client.cancelResponseInput("session-1", input.id)).resolves.toEqual({ ...input, status: "cancelled" });
expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("DELETE");
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://agent.local/api/v1/responses");
expect(String(fetchMock.mock.calls[2]?.[0])).toContain(`/api/v1/session/session-1/inputs/${input.id}`);
for (const [, request] of fetchMock.mock.calls) {
@@ -83,7 +112,8 @@ describe("Agent API client sessions", () => {
runStatus: "running",
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
trigger: undefined,
topicId: null, topicName: undefined, searchSnippet: undefined
},
{
id: "old",
@@ -96,7 +126,8 @@ describe("Agent API client sessions", () => {
runStatus: undefined,
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
trigger: undefined,
topicId: null, topicName: undefined, searchSnippet: undefined
}
]);
});
+85 -8
View File
@@ -39,12 +39,15 @@ export type AgentModelsResponse = {
};
export type AgentInputDelivery = "steer" | "queue";
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" | "cancelled" };
export type AgentTopic = { id: string; name: string; createdAt: string; updatedAt: string };
export type AgentChatSession = {
id?: string;
session_id: string;
title?: string;
topic_id?: string | null;
status?: string;
run_status?: AgentRunStatus;
is_streaming?: boolean;
@@ -64,12 +67,16 @@ export type AgentChatSessionSummary = {
trigger?: { subjectId: string; workflow: "water_quality_complaint"; occurredAt: string };
id: string;
title: string;
topicId?: string | null;
topicName?: string;
searchSnippet?: string;
createdAt: number;
updatedAt: number;
status?: string;
parentSessionId?: string;
isStreaming?: boolean;
runStatus?: AgentRunStatus;
needsInput?: boolean;
purpose: AgentSessionPurpose;
taskId?: string;
};
@@ -89,10 +96,15 @@ export type AgentPromptSuggestion = {
activityId: string | null;
};
export type AgentMessagePagination = { hasMore: boolean; beforeCursor: string | null };
export type AgentSessionPage = { sessions: AgentChatSessionSummary[]; hasMore: boolean; nextCursor: string | null };
export type AgentHistoryMessagePage = { messages: unknown[]; messagePage: AgentMessagePagination };
export type AgentLoadedChatSession = AgentChatSessionSummary & {
sessionId: string;
isTitleManuallyEdited: boolean;
messages: unknown[];
messagePage?: AgentMessagePagination;
};
export type AgentSessionStreamDataEventType =
@@ -124,6 +136,7 @@ export type AgentSessionStreamEvent =
| {
type: "state";
executionPlans?: unknown[];
messagePage?: AgentMessagePagination;
sessionId: string;
messages: unknown[];
isStreaming: boolean;
@@ -136,20 +149,30 @@ export type AgentSessionStreamEvent =
};
export type AgentApiClient = {
downloadFile: (path: string) => Promise<void>;
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
cancelResponseInput: (sessionId: string, id: string) => Promise<AgentResponseInput>;
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
createSession: (options?: { triggerContext?: AgentEventTriggerContext; topicId?: string }) => Promise<AgentChatSession>;
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
listSessionPage: (options?: { q?: string; cursor?: string; limit?: number; purpose?: AgentSessionPurpose; topicId?: string; includeCollaboration?: boolean; signal?: AbortSignal }) => Promise<AgentSessionPage>;
listTopics: () => Promise<AgentTopic[]>;
createTopic: (name: string) => Promise<AgentTopic>;
renameTopic: (id: string, name: string) => Promise<AgentTopic>;
deleteTopic: (id: string) => Promise<void>;
moveSessionToTopic: (sessionId: string, topicId: string | null) => Promise<void>;
loadOlderMessages: (sessionId: string, before: string, signal?: AbortSignal) => Promise<AgentHistoryMessagePage>;
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
loadSession: (sessionId: string, options?: { messageLimit?: number; signal?: AbortSignal }) => Promise<AgentLoadedChatSession | null>;
streamSession: (
sessionId: string,
options: {
signal?: AbortSignal;
messageLimit?: number;
onEvent: (event: AgentSessionStreamEvent) => void;
}
) => Promise<void>;
@@ -218,6 +241,19 @@ export function createAgentApiClient(
};
return {
async downloadFile(path) {
const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path);
if (!match) throw new Error("无效的会话文件地址");
const response = await request(match[1], { redirect: "error" });
if (!response.ok) throw new Error("文件不可用或当前账号无权下载");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = path.split("/").at(-1) ?? "export.zip";
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
},
async submitResponseInput(sessionId, text, delivery, id) {
return requestJson<AgentResponseInput>("/responses", {
method: "POST", headers: { "Content-Type": "application/json" },
@@ -233,6 +269,11 @@ export function createAgentApiClient(
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
});
},
async cancelResponseInput(sessionId, id) {
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
method: "DELETE"
});
},
async generatePromptSuggestions(activities, model, signal) {
const result = await requestJson<unknown>("/suggestions", {
method: "POST",
@@ -260,7 +301,7 @@ export function createAgentApiClient(
return requestJson<AgentChatSession>("/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger_context: options?.triggerContext })
body: JSON.stringify({ trigger_context: options?.triggerContext, topic_id: options?.topicId })
});
},
@@ -274,6 +315,31 @@ export function createAgentApiClient(
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async listSessionPage(options) {
const params = new URLSearchParams({ purpose: options?.purpose ?? "ordinary_chat", limit: String(options?.limit ?? 30) });
if (options?.q) params.set("q", options.q);
if (options?.topicId) params.set("topic_id", options.topicId);
if (options?.includeCollaboration) params.set("include_collaboration", "true");
if (options?.cursor) params.set("cursor", options.cursor);
const payload = await requestJson<{ sessions?: unknown[]; has_more?: boolean; next_cursor?: string | null }>(
`/sessions?${params}`, { signal: options?.signal });
return { sessions: (payload.sessions ?? []).map(toSessionSummary).filter(isPresent),
hasMore: payload.has_more === true, nextCursor: typeof payload.next_cursor === "string" ? payload.next_cursor : null };
},
async listTopics() { return (await requestJson<{ topics: AgentTopic[] }>("/topics")).topics; },
async createTopic(name) { return requestJson<AgentTopic>("/topics", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); },
async renameTopic(id, name) { return requestJson<AgentTopic>(`/topics/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); },
async deleteTopic(id) { await requestJson(`/topics/${encodeURIComponent(id)}`, { method: "DELETE" }); },
async moveSessionToTopic(sessionId, topicId) { await requestJson(`/session/${encodeURIComponent(sessionId)}/topic`, {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ topic_id: topicId }),
}); },
async loadOlderMessages(sessionId, before, signal) {
const query = new URLSearchParams({ limit: "40", before });
const payload = await requestJson<{ messages?: unknown[]; message_page?: unknown }>(
`/session/${encodeURIComponent(sessionId)}/messages?${query}`, { signal });
return { messages: payload.messages ?? [], messagePage: readMessagePagination(payload.message_page) ?? { hasMore: false, beforeCursor: null } };
},
async createTaskCollaborationSession(taskId) {
return requestJson<AgentChatSession>(
`/task/${encodeURIComponent(taskId)}/collaboration/session`,
@@ -293,8 +359,9 @@ export function createAgentApiClient(
});
},
async loadSession(sessionId) {
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
async loadSession(sessionId, options) {
const query = options?.messageLimit ? `?message_limit=${options.messageLimit}` : "";
const response = await request(`/session/${encodeURIComponent(sessionId)}${query}`, { signal: options?.signal });
const text = await response.text();
const data = text ? JSON.parse(text) : null;
@@ -310,7 +377,7 @@ export function createAgentApiClient(
async streamSession(sessionId, options) {
const response = await request(
`/session/${encodeURIComponent(sessionId)}/stream`,
`/session/${encodeURIComponent(sessionId)}/stream${options.messageLimit ? `?message_limit=${options.messageLimit}` : ""}`,
{ signal: options.signal }
);
@@ -564,6 +631,9 @@ function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
return {
id,
title: normalizeTitle(value.title),
topicId: typeof value.topic_id === "string" ? value.topic_id : null,
topicName: typeof value.topic_name === "string" ? value.topic_name : undefined,
searchSnippet: typeof value.search_snippet === "string" ? value.search_snippet : undefined,
createdAt: toMillis(value.created_at),
updatedAt: toMillis(value.updated_at),
status: typeof value.status === "string" ? value.status : undefined,
@@ -591,7 +661,8 @@ function toLoadedSession(value: unknown): AgentLoadedChatSession {
sessionId: typeof value.session_id === "string" ? value.session_id : summary.id,
isTitleManuallyEdited:
typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false,
messages: Array.isArray(value.messages) ? value.messages : []
messages: Array.isArray(value.messages) ? value.messages : [],
messagePage: readMessagePagination(value.message_page)
};
}
@@ -657,6 +728,7 @@ function emitSessionSseEvent(
onEvent({
type: "state",
executionPlans: Array.isArray(data.execution_plans) ? data.execution_plans : undefined,
messagePage: readMessagePagination(data.message_page),
sessionId,
messages: Array.isArray(data.messages) ? data.messages : [],
isStreaming: data.is_streaming === true,
@@ -717,3 +789,8 @@ function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChat
return right.id.localeCompare(left.id);
}
function readMessagePagination(value: unknown): AgentMessagePagination | undefined {
if (!isRecord(value)) return undefined;
return { hasMore: value.has_more === true, beforeCursor: typeof value.before_cursor === "string" ? value.before_cursor : null };
}
@@ -32,7 +32,7 @@ describe("Responses transport", () => {
const messages = [{ id: "old", role: "assistant", parts: [{ type: "text", text: "旧回答" }] }, { id: "new", role: "user", parts: [{ type: "text", text: "继续核查" }] }] as UIMessage[];
expect(createResponseRequest(messages, { conversation: "owned", approvalMode: "always" })).toEqual({
input: [{ role: "user", content: [{ type: "input_text", text: "继续核查" }] }], stream: true, conversation: "owned",
tjwater: { approval_mode: "always", capabilities: [] },
tjwater: { client_message_id: "new", approval_mode: "always", capabilities: [] },
});
});
it("decodes split UTF-8 and CRLF while retaining approval events", async () => {
@@ -4,7 +4,7 @@ const record = (value: unknown): value is Record<string, unknown> => typeof valu
export function createResponseRequest(messages: UIMessage[], options: {
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
triggerContext?: unknown; taskContext?: unknown;
triggerContext?: unknown; taskContext?: unknown; topicId?: string;
}) {
const latest = [...messages].reverse().find((message) => message.role === "user");
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
@@ -13,7 +13,7 @@ export function createResponseRequest(messages: UIMessage[], options: {
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
...(options.model ? { model: options.model } : {}),
...(options.conversation ? { conversation: options.conversation } : {}),
tjwater: { approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
tjwater: { ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
...(options.taskContext ? { task_context: options.taskContext } : {}),
},
@@ -44,6 +44,9 @@ export function responsesToUiStream(stream: ReadableStream<Uint8Array>): Readabl
if (value.type === "response.created") {
if (responseId || !record(value.response) || typeof value.response.id !== "string") throw new Error("Responses 创建事件不正确");
responseId = value.response.id;
if (record(value.response.conversation) && typeof value.response.conversation.id === "string") {
controller.enqueue({ type: "data-session_created", data: { session_id: value.response.conversation.id }, transient: true });
}
return;
}
if (!responseId) throw new Error("Responses 流缺少创建事件");