Files
next-tjwater-frontend/src/features/agent/session-state.ts
T
jiang 692bbf609c refactor: consolidate requests and finalize agent tool states
Share project JSON transport, interaction replies and report selectors.
Settle tool progress consistently for direct streams, reconnects and history, preserving failure reasons and stopped state.
2026-11-09 17:55:00 +08:00

404 lines
13 KiB
TypeScript

import type {
AgentChatMessage,
AgentChatProgress,
AgentInteractionToolRef,
AgentPermissionRequest,
AgentPermissionStatus,
AgentQuestionInfo,
AgentQuestionRequest,
AgentTodoItem,
AgentTodoUpdate
} from "./types";
type RecordValue = Record<string, unknown>;
export function upsertProgress(progress: AgentChatProgress[] | undefined, data: unknown) {
const payload = asRecord(data);
const id = readString(payload.id) ?? `progress-${Date.now().toString(36)}`;
const now = Date.now();
const index = progress?.findIndex((item) => item.id === id) ?? -1;
const existing = index >= 0 ? progress?.[index] : undefined;
const status = readProgressStatus(payload.status);
const startedAt = readNumber(payload.started_at) ?? readNumber(payload.startedAt) ?? existing?.startedAt;
const endedAt = readNumber(payload.ended_at) ?? readNumber(payload.endedAt);
const elapsedMs = readNumber(payload.elapsed_ms) ?? readNumber(payload.elapsedMs);
const durationMs = readNumber(payload.duration_ms) ?? readNumber(payload.durationMs);
const nextItem: AgentChatProgress = {
id,
phase: readString(payload.phase) ?? "progress",
status,
title: readString(payload.title) ?? "正在处理",
detail: readString(payload.detail),
tool: readProgressTool(payload.tool) ?? existing?.tool,
startedAt,
endedAt,
elapsedMs: status === "running" ? elapsedMs : undefined,
elapsedSnapshotAt: status === "running" && elapsedMs !== undefined ? now : undefined,
durationMs: status === "running" ? undefined : durationMs
};
const next = [...(progress ?? [])];
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
}
export function completeRunningProgress(
progress: AgentChatProgress[] | undefined,
status: "completed" | "error" | "cancelled" = "completed"
) {
return progress?.map((item) => {
if (item.status !== "running") {
return item;
}
const endedAt = Date.now();
return {
...item,
status,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
item.durationMs ??
(item.startedAt !== undefined ? Math.max(0, endedAt - item.startedAt) : item.elapsedMs)
};
});
}
export function toTodoUpdate(data: unknown): AgentTodoUpdate | null {
const payload = asRecord(data);
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
const rawTodos = Array.isArray(payload.todos) ? payload.todos : [];
if (!sessionId || rawTodos.length === 0) {
return null;
}
return {
sessionId,
messageId: readString(payload.message_id) ?? readString(payload.messageId),
todos: rawTodos.map(toTodoItem).filter((item): item is AgentTodoItem => Boolean(item)),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now()
};
}
export function cancelRunningTodos(todoUpdate: AgentTodoUpdate | undefined) {
return todoUpdate
? {
...todoUpdate,
todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress"
? {
...todo,
status: "cancelled" as const,
updatedAt: Date.now()
}
: todo
)
}
: undefined;
}
export function upsertPermission(
permissions: AgentPermissionRequest[] | undefined,
data: unknown
) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
if (!requestId || !sessionId) {
return permissions;
}
const nextItem: AgentPermissionRequest = {
requestId,
sessionId,
permission: readString(payload.permission) ?? "unknown",
patterns: readStringArray(payload.patterns),
target: readString(payload.target),
always: readStringArray(payload.always),
tool: toToolRef(payload.tool),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
status: "pending"
};
const next = [...(permissions ?? [])];
const index = next.findIndex((permission) => permission.requestId === requestId);
if (index >= 0) {
next[index] = {
...next[index],
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status
};
} else {
next.push(nextItem);
}
return next;
}
export function applyPermissionResponse(
permissions: AgentPermissionRequest[] | undefined,
data: unknown
) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
if (!requestId) {
return permissions;
}
return permissions?.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: payload.status === "error" ? "error" as const : toPermissionStatus(readString(payload.reply)),
repliedAt: Date.now(),
error: payload.status === "error" ? readString(payload.error) : undefined
}
: permission
);
}
export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, data: unknown) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
const rawQuestions = Array.isArray(payload.questions) ? payload.questions : [];
if (!requestId || !sessionId || rawQuestions.length === 0) {
return questions;
}
const nextItem: AgentQuestionRequest = {
requestId,
sessionId,
questions: rawQuestions.map(toQuestionInfo).filter((item): item is AgentQuestionInfo => Boolean(item)),
tool: toToolRef(payload.tool),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
status: "pending"
};
const next = [...(questions ?? [])];
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
if (index >= 0) {
const current = next[index];
const currentIsActionable = Boolean(current.tool?.callID && current.requestId !== current.tool.callID);
const nextIsToolPlaceholder = Boolean(nextItem.tool?.callID && nextItem.requestId === nextItem.tool.callID);
next[index] =
currentIsActionable && nextIsToolPlaceholder
? {
...current,
sessionId: nextItem.sessionId,
questions: nextItem.questions,
tool: nextItem.tool ?? current.tool,
createdAt: nextItem.createdAt
}
: {
...current,
...nextItem,
status: current.status === "submitting" ? "submitting" : nextItem.status
};
} else {
next.push(nextItem);
}
return next;
}
export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefined, data: unknown) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
if (!requestId) {
return questions;
}
const answers = Array.isArray(payload.answers)
? payload.answers.map((answer) => (Array.isArray(answer) ? answer.map(String) : []))
: undefined;
const rejected = payload.rejected === true;
return questions?.map((question) =>
question.requestId === requestId
? {
...question,
status: rejected ? ("rejected" as const) : ("answered" as const),
answers: answers ?? question.answers,
repliedAt: Date.now(),
error: undefined
}
: question
);
}
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
const progress = completeRunningProgress(message.progress, "cancelled");
const todos = cancelRunningTodos(message.todos);
return {
...message,
content: message.content || "请求已中止。",
progress,
todos,
permissions: message.permissions?.map((permission) =>
permission.status === "pending" || permission.status === "submitting" || permission.status === "error"
? {
...permission,
status: "aborted" as const,
repliedAt: Date.now(),
error: undefined
}
: permission
),
questions: message.questions?.map((question) =>
question.status === "pending" || question.status === "submitting" || question.status === "error"
? {
...question,
status: "rejected" as const,
repliedAt: Date.now(),
error: undefined
}
: question
)
};
}
export function finalizeAssistantMessageAfterError(message: AgentChatMessage, reason: string): AgentChatMessage {
const settled = finalizeAssistantMessageAfterAbort(message);
return {
...settled,
content: message.content || `错误:${reason}`,
runError: reason,
progress: settled.progress?.map((item, index) => message.progress?.[index].status === "running"
? { ...item, status: "error", detail: item.detail ? `${item.detail}\n${reason}` : reason }
: item)
};
}
function toPermissionStatus(reply: string | undefined): AgentPermissionStatus {
if (reply === "always") {
return "approved_always";
}
if (reply === "once") {
return "approved_once";
}
return "rejected";
}
function isSameQuestion(left: AgentQuestionRequest, right: AgentQuestionRequest) {
if (left.requestId === right.requestId) {
return true;
}
return Boolean(left.tool?.callID && right.tool?.callID && left.tool.callID === right.tool.callID);
}
function toQuestionInfo(value: unknown): AgentQuestionInfo | null {
const payload = asRecord(value);
const question = readString(payload.question);
if (!question) {
return null;
}
return {
header: readString(payload.header) ?? "",
question,
options: Array.isArray(payload.options)
? payload.options
.map((option) => {
const item = asRecord(option);
const label = readString(item.label);
return label
? {
label,
description: readString(item.description) ?? ""
}
: null;
})
.filter((item): item is { label: string; description: string } => Boolean(item))
: [],
multiple: typeof payload.multiple === "boolean" ? payload.multiple : undefined,
custom: typeof payload.custom === "boolean" ? payload.custom : undefined
};
}
function toTodoItem(value: unknown): AgentTodoItem | null {
const payload = asRecord(value);
const id = readString(payload.id);
const content = readString(payload.content);
const status = readTodoStatus(payload.status);
if (!id || !content || !status) {
return null;
}
return {
id,
content,
status,
priority: readTodoPriority(payload.priority),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt),
updatedAt: readNumber(payload.updated_at) ?? readNumber(payload.updatedAt)
};
}
function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
const payload = asRecord(value);
const messageID = readString(payload.messageID);
const callID = readString(payload.callID);
return messageID && callID ? { messageID, callID } : undefined;
}
function readProgressStatus(value: unknown): AgentChatProgress["status"] {
return value === "completed" || value === "error" || value === "cancelled" ? value : "running";
}
function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined {
if (value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled") {
return value;
}
return undefined;
}
function readTodoPriority(value: unknown): AgentTodoItem["priority"] | undefined {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return undefined;
}
function asRecord(value: unknown): RecordValue {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as RecordValue)
: {};
}
function readString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readStringArray(value: unknown) {
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
}
export function readProgressTool(value: unknown): AgentChatProgress["tool"] {
const tool = asRecord(value);
const name = readString(tool.name);
if (!name) return undefined;
return {
name,
callId: readString(tool.call_id) ?? readString(tool.callId),
input: tool.input && typeof tool.input === "object" && !Array.isArray(tool.input)
? tool.input as Record<string, unknown> : undefined
};
}