First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function InvalidRoutePage() {
|
||||
redirect("/office");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AgentSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ agentId?: string }> | { agentId?: string };
|
||||
}) {
|
||||
await params;
|
||||
redirect("/office");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AgentsPage() {
|
||||
redirect("/office");
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local";
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import {
|
||||
resolveConfiguredSshTarget,
|
||||
resolveGatewaySshTargetFromGatewayUrl,
|
||||
} from "@/lib/ssh/gateway-host";
|
||||
import {
|
||||
restoreAgentStateOverSsh,
|
||||
trashAgentStateOverSsh,
|
||||
} from "@/lib/ssh/agent-state";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type TrashAgentStateRequest = {
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
type RestoreAgentStateRequest = {
|
||||
agentId: string;
|
||||
trashDir: string;
|
||||
};
|
||||
|
||||
const isSafeAgentId = (value: string) => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value);
|
||||
|
||||
const resolveAgentStateSshTarget = (): string | null => {
|
||||
const configured = resolveConfiguredSshTarget(process.env);
|
||||
if (configured) return configured;
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url ?? "";
|
||||
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
|
||||
}
|
||||
const { agentId } = body as Partial<TrashAgentStateRequest>;
|
||||
const trimmed = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!trimmed) {
|
||||
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
|
||||
}
|
||||
if (!isSafeAgentId(trimmed)) {
|
||||
return NextResponse.json({ error: `Invalid agentId: ${trimmed}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const sshTarget = resolveAgentStateSshTarget();
|
||||
const result = sshTarget
|
||||
? trashAgentStateOverSsh({ sshTarget, agentId: trimmed })
|
||||
: trashAgentStateLocally({ agentId: trimmed });
|
||||
return NextResponse.json({ result });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to trash agent workspace/state.";
|
||||
console.error(message);
|
||||
const status =
|
||||
message.includes("Invalid request payload") ||
|
||||
message.includes("agentId is required") ||
|
||||
message.includes("trashDir is required") ||
|
||||
message.includes("Invalid agentId") ||
|
||||
message.includes("Gateway URL is missing") ||
|
||||
message.includes("Invalid gateway URL") ||
|
||||
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
|
||||
? 400
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
|
||||
}
|
||||
const { agentId, trashDir } = body as Partial<RestoreAgentStateRequest>;
|
||||
const trimmedAgent = typeof agentId === "string" ? agentId.trim() : "";
|
||||
const trimmedTrash = typeof trashDir === "string" ? trashDir.trim() : "";
|
||||
if (!trimmedAgent) {
|
||||
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
|
||||
}
|
||||
if (!trimmedTrash) {
|
||||
return NextResponse.json({ error: "trashDir is required." }, { status: 400 });
|
||||
}
|
||||
if (!isSafeAgentId(trimmedAgent)) {
|
||||
return NextResponse.json({ error: `Invalid agentId: ${trimmedAgent}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const sshTarget = resolveAgentStateSshTarget();
|
||||
const result = sshTarget
|
||||
? restoreAgentStateOverSsh({
|
||||
sshTarget,
|
||||
agentId: trimmedAgent,
|
||||
trashDir: trimmedTrash,
|
||||
})
|
||||
: restoreAgentStateLocally({
|
||||
agentId: trimmedAgent,
|
||||
trashDir: trimmedTrash,
|
||||
});
|
||||
return NextResponse.json({ result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to restore agent state.";
|
||||
console.error(message);
|
||||
const status =
|
||||
message.includes("Invalid request payload") ||
|
||||
message.includes("agentId is required") ||
|
||||
message.includes("trashDir is required") ||
|
||||
message.includes("Invalid agentId") ||
|
||||
message.includes("Gateway URL is missing") ||
|
||||
message.includes("Invalid gateway URL") ||
|
||||
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
|
||||
? 400
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import {
|
||||
resolveConfiguredSshTarget,
|
||||
resolveGatewaySshTargetFromGatewayUrl,
|
||||
runSshJson,
|
||||
} from "@/lib/ssh/gateway-host";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
const expandTildeLocal = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "~") return os.homedir();
|
||||
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const validateRawMediaPath = (raw: string): { trimmed: string; mime: string } => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) throw new Error("path is required");
|
||||
if (trimmed.length > 4096) throw new Error("path too long");
|
||||
if (/[^\S\r\n]*[\0\r\n]/.test(trimmed)) throw new Error("path contains invalid characters");
|
||||
|
||||
const ext = path.extname(trimmed).toLowerCase();
|
||||
const mime = MIME_BY_EXT[ext];
|
||||
if (!mime) throw new Error(`Unsupported media extension: ${ext || "(none)"}`);
|
||||
|
||||
return { trimmed, mime };
|
||||
};
|
||||
|
||||
const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime: string } => {
|
||||
const { trimmed, mime } = validateRawMediaPath(raw);
|
||||
|
||||
const expanded = expandTildeLocal(trimmed);
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
throw new Error("path must be absolute or start with ~/");
|
||||
}
|
||||
|
||||
const resolved = path.resolve(expanded);
|
||||
|
||||
const allowedRoot = path.join(os.homedir(), ".openclaw");
|
||||
const allowedPrefix = `${allowedRoot}${path.sep}`;
|
||||
if (!(resolved === allowedRoot || resolved.startsWith(allowedPrefix))) {
|
||||
throw new Error(`Refusing to read media outside ${allowedRoot}`);
|
||||
}
|
||||
|
||||
return { resolved, mime };
|
||||
};
|
||||
|
||||
const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => {
|
||||
const { trimmed, mime } = validateRawMediaPath(raw);
|
||||
|
||||
if (!(trimmed.startsWith("/") || trimmed === "~" || trimmed.startsWith("~/"))) {
|
||||
throw new Error("path must be absolute or start with ~/");
|
||||
}
|
||||
|
||||
// Remote side enforces ~/.openclaw; this guard lets Studio on macOS request
|
||||
// /home/ubuntu/.openclaw/... without tripping local homedir checks.
|
||||
const normalized = trimmed.replaceAll("\\\\", "/");
|
||||
const inOpenclaw =
|
||||
normalized === "~/.openclaw" ||
|
||||
normalized.startsWith("~/.openclaw/") ||
|
||||
normalized.includes("/.openclaw/");
|
||||
if (!inOpenclaw) {
|
||||
throw new Error("Refusing to read remote media outside ~/.openclaw");
|
||||
}
|
||||
|
||||
return { remotePath: trimmed, mime };
|
||||
};
|
||||
|
||||
const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("path is not a file");
|
||||
}
|
||||
if (stat.size > MAX_MEDIA_BYTES) {
|
||||
throw new Error(`media file too large (${stat.size} bytes)`);
|
||||
}
|
||||
const buf = await fs.readFile(resolvedPath);
|
||||
return { bytes: buf, size: stat.size };
|
||||
};
|
||||
|
||||
const REMOTE_READ_SCRIPT = `
|
||||
set -euo pipefail
|
||||
|
||||
python3 - "$1" <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
raw = sys.argv[1].strip()
|
||||
if not raw:
|
||||
print(json.dumps({"error": "path is required"}))
|
||||
raise SystemExit(2)
|
||||
|
||||
p = pathlib.Path(os.path.expanduser(raw))
|
||||
try:
|
||||
resolved = p.resolve(strict=True)
|
||||
except FileNotFoundError:
|
||||
print(json.dumps({"error": f"file not found: {raw}"}))
|
||||
raise SystemExit(3)
|
||||
|
||||
home = pathlib.Path.home().resolve()
|
||||
allowed = (home / ".openclaw").resolve()
|
||||
if resolved != allowed and allowed not in resolved.parents:
|
||||
print(json.dumps({"error": f"Refusing to read media outside {allowed}"}))
|
||||
raise SystemExit(4)
|
||||
|
||||
ext = resolved.suffix.lower()
|
||||
mime = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}.get(ext) or (mimetypes.guess_type(str(resolved))[0] or "")
|
||||
|
||||
if not mime.startswith("image/"):
|
||||
print(json.dumps({"error": f"Unsupported media extension: {ext or '(none)'}"}))
|
||||
raise SystemExit(5)
|
||||
|
||||
size = resolved.stat().st_size
|
||||
max_bytes = ${MAX_MEDIA_BYTES}
|
||||
if size > max_bytes:
|
||||
print(json.dumps({"error": f"media file too large ({size} bytes)"}))
|
||||
raise SystemExit(6)
|
||||
|
||||
data = base64.b64encode(resolved.read_bytes()).decode("ascii")
|
||||
print(json.dumps({"ok": True, "mime": mime, "size": size, "data": data}))
|
||||
PY
|
||||
`;
|
||||
|
||||
const resolveSshTarget = (): string | null => {
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url ?? "";
|
||||
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||
const configured = resolveConfiguredSshTarget(process.env);
|
||||
if (configured) return configured;
|
||||
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawPath = (searchParams.get("path") ?? "").trim();
|
||||
|
||||
const sshTarget = resolveSshTarget();
|
||||
|
||||
if (!sshTarget) {
|
||||
const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath);
|
||||
const { bytes, size } = await readLocalMedia(resolved);
|
||||
const body = new Blob([Uint8Array.from(bytes)], { type: mime });
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Content-Length": String(size),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { remotePath, mime } = validateRemoteMediaPath(rawPath);
|
||||
|
||||
const payload = runSshJson({
|
||||
sshTarget,
|
||||
argv: ["bash", "-s", "--", remotePath],
|
||||
label: "gateway media read",
|
||||
input: REMOTE_READ_SCRIPT,
|
||||
fallbackMessage: `Failed to fetch media over ssh (${sshTarget})`,
|
||||
maxBuffer: Math.ceil(MAX_MEDIA_BYTES * 1.6),
|
||||
}) as {
|
||||
ok?: boolean;
|
||||
data?: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const b64 = payload.data ?? "";
|
||||
if (!b64) {
|
||||
throw new Error("Remote media fetch returned empty data");
|
||||
}
|
||||
|
||||
const buf = Buffer.from(b64, "base64");
|
||||
const responseMime = payload.mime || mime;
|
||||
const body = new Blob([Uint8Array.from(buf)], { type: responseMime });
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": responseMime,
|
||||
"Content-Length": String(buf.length),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch media";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import { removeSkillLocally } from "@/lib/skills/remove-local";
|
||||
import type { RemovableSkillSource, SkillRemoveRequest } from "@/lib/skills/types";
|
||||
import {
|
||||
resolveConfiguredSshTarget,
|
||||
resolveGatewaySshTargetFromGatewayUrl,
|
||||
} from "@/lib/ssh/gateway-host";
|
||||
import { removeSkillOverSsh } from "@/lib/ssh/skills-remove";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const REMOVABLE_SOURCES = new Set<RemovableSkillSource>([
|
||||
"openclaw-managed",
|
||||
"openclaw-workspace",
|
||||
]);
|
||||
|
||||
const normalizeRequired = (value: unknown, field: string): string => {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${field} is required.`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${field} is required.`);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const resolveSkillRemovalSshTarget = (): string | null => {
|
||||
const configured = resolveConfiguredSshTarget(process.env);
|
||||
if (configured) return configured;
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url ?? "";
|
||||
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||
};
|
||||
|
||||
const normalizeRemoveRequest = (body: unknown): SkillRemoveRequest => {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new Error("Invalid request payload.");
|
||||
}
|
||||
|
||||
const record = body as Partial<Record<keyof SkillRemoveRequest, unknown>>;
|
||||
const sourceRaw = normalizeRequired(record.source, "source");
|
||||
if (!REMOVABLE_SOURCES.has(sourceRaw as RemovableSkillSource)) {
|
||||
throw new Error(`Unsupported skill source for removal: ${sourceRaw}`);
|
||||
}
|
||||
|
||||
return {
|
||||
skillKey: normalizeRequired(record.skillKey, "skillKey"),
|
||||
source: sourceRaw as RemovableSkillSource,
|
||||
baseDir: normalizeRequired(record.baseDir, "baseDir"),
|
||||
workspaceDir: normalizeRequired(record.workspaceDir, "workspaceDir"),
|
||||
managedSkillsDir: normalizeRequired(record.managedSkillsDir, "managedSkillsDir"),
|
||||
};
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
const removeRequest = normalizeRemoveRequest(body);
|
||||
|
||||
const sshTarget = resolveSkillRemovalSshTarget();
|
||||
const result = sshTarget
|
||||
? removeSkillOverSsh({ sshTarget, request: removeRequest })
|
||||
: removeSkillLocally(removeRequest);
|
||||
|
||||
return NextResponse.json({ result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to remove skill.";
|
||||
const status =
|
||||
message.includes("required") ||
|
||||
message.includes("Invalid request payload") ||
|
||||
message.includes("Unsupported skill source") ||
|
||||
message.includes("Refusing to remove") ||
|
||||
message.includes("not a directory") ||
|
||||
message.includes("Gateway URL is missing") ||
|
||||
message.includes("Invalid gateway URL") ||
|
||||
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
|
||||
? 400
|
||||
: 500;
|
||||
if (status >= 500) {
|
||||
console.error(message);
|
||||
}
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
normalizeBrowserPreviewUrl,
|
||||
resolveBrowserControlBaseUrl,
|
||||
} from "@/lib/office/browserPreview";
|
||||
import { validateBrowserPreviewTarget } from "@/lib/security/urlSafety";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type BrowserTab = {
|
||||
targetId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type BrowserTabsResponse = {
|
||||
running?: boolean;
|
||||
tabs?: BrowserTab[];
|
||||
};
|
||||
|
||||
type BrowserOpenResponse = {
|
||||
targetId?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type BrowserScreenshotResponse = {
|
||||
ok?: boolean;
|
||||
path?: string;
|
||||
targetId?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CAPTURE_WAIT_MS = 1_500;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const buildBrowserHeaders = (token: string | null): HeadersInit => {
|
||||
if (!token) return { "Content-Type": "application/json" };
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
};
|
||||
|
||||
const parseBrowserError = async (response: Response): Promise<string> => {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string; message?: string };
|
||||
return payload.error?.trim() || payload.message?.trim() || response.statusText || "Browser request failed";
|
||||
} catch {
|
||||
return response.statusText || "Browser request failed";
|
||||
}
|
||||
};
|
||||
|
||||
const browserRequest = async <T>(
|
||||
baseUrl: string,
|
||||
pathname: string,
|
||||
init: RequestInit,
|
||||
token: string | null,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(`${baseUrl}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...buildBrowserHeaders(token),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseBrowserError(response));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
const samePreviewTarget = (left: string, right: string): boolean => {
|
||||
return normalizeBrowserPreviewUrl(left) === normalizeBrowserPreviewUrl(right);
|
||||
};
|
||||
|
||||
const ensurePreviewTab = async (
|
||||
baseUrl: string,
|
||||
token: string | null,
|
||||
browserUrl: string,
|
||||
): Promise<{ targetId: string; resolvedUrl: string }> => {
|
||||
const tabsPayload = await browserRequest<BrowserTabsResponse>(baseUrl, "/tabs", { method: "GET" }, token);
|
||||
|
||||
if (tabsPayload.running === false) {
|
||||
await browserRequest(baseUrl, "/start", { method: "POST" }, token);
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
const tabs = Array.isArray(tabsPayload.tabs) ? tabsPayload.tabs : [];
|
||||
const exactMatch = tabs.find((tab) => samePreviewTarget(tab.url, browserUrl));
|
||||
if (exactMatch?.targetId) {
|
||||
await browserRequest(baseUrl, "/tabs/focus", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ targetId: exactMatch.targetId }),
|
||||
}, token);
|
||||
return { targetId: exactMatch.targetId, resolvedUrl: exactMatch.url || browserUrl };
|
||||
}
|
||||
|
||||
const reusableTab = tabs[0];
|
||||
if (reusableTab?.targetId) {
|
||||
await browserRequest(baseUrl, "/tabs/focus", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ targetId: reusableTab.targetId }),
|
||||
}, token);
|
||||
await browserRequest(baseUrl, "/navigate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ targetId: reusableTab.targetId, url: browserUrl }),
|
||||
}, token);
|
||||
return { targetId: reusableTab.targetId, resolvedUrl: browserUrl };
|
||||
}
|
||||
|
||||
const opened = await browserRequest<BrowserOpenResponse>(baseUrl, "/tabs/open", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: browserUrl }),
|
||||
}, token);
|
||||
if (!opened.targetId) {
|
||||
throw new Error("Browser preview did not return a target tab.");
|
||||
}
|
||||
return { targetId: opened.targetId, resolvedUrl: opened.url || browserUrl };
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawUrl = (searchParams.get("url") ?? "").trim();
|
||||
if (!rawUrl) {
|
||||
return NextResponse.json({ error: "url is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let browserUrl: string;
|
||||
try {
|
||||
browserUrl = validateBrowserPreviewTarget(rawUrl).toString();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "url must be an absolute public http(s) URL" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const waitMsRaw = Number(searchParams.get("waitMs") ?? "");
|
||||
const waitMs =
|
||||
Number.isFinite(waitMsRaw) && waitMsRaw >= 0
|
||||
? Math.min(Math.round(waitMsRaw), 8_000)
|
||||
: DEFAULT_CAPTURE_WAIT_MS;
|
||||
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url?.trim() ?? "";
|
||||
const controlBaseUrl = resolveBrowserControlBaseUrl(gatewayUrl);
|
||||
if (!controlBaseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "Browser screenshot preview only works when Studio is connected to a local gateway." },
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
const token = settings.gateway?.token?.trim() || null;
|
||||
const { targetId, resolvedUrl } = await ensurePreviewTab(controlBaseUrl, token, browserUrl);
|
||||
|
||||
if (waitMs > 0) {
|
||||
await sleep(waitMs);
|
||||
}
|
||||
|
||||
const screenshot = await browserRequest<BrowserScreenshotResponse>(controlBaseUrl, "/screenshot", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ targetId, type: "png" }),
|
||||
}, token);
|
||||
|
||||
if (!screenshot.path?.trim()) {
|
||||
throw new Error("Browser screenshot did not return a media path.");
|
||||
}
|
||||
|
||||
const mediaUrl = `/api/gateway/media?path=${encodeURIComponent(screenshot.path)}`;
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
browserUrl: screenshot.url || resolvedUrl,
|
||||
imagePath: screenshot.path,
|
||||
mediaUrl,
|
||||
targetId: screenshot.targetId || targetId,
|
||||
capturedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to build browser preview";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { buildMockPhoneCallScenario } from "@/lib/office/call/mock";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type PhoneCallRequestBody = {
|
||||
callee?: string;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
const MAX_CALLEE_CHARS = 120;
|
||||
const MAX_MESSAGE_CHARS = 1_000;
|
||||
|
||||
const normalizeText = (value: string | null | undefined): string =>
|
||||
(value ?? "").replace(/\s+/g, " ").trim();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as PhoneCallRequestBody;
|
||||
const callee = normalizeText(body.callee);
|
||||
const message = normalizeText(body.message);
|
||||
|
||||
if (!callee) {
|
||||
return NextResponse.json({ error: "callee is required." }, { status: 400 });
|
||||
}
|
||||
if (callee.length > MAX_CALLEE_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `callee exceeds ${MAX_CALLEE_CHARS} characters.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (message.length > MAX_MESSAGE_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `message exceeds ${MAX_MESSAGE_CHARS} characters.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Create Claw3D voice and text skill.
|
||||
const scenario = buildMockPhoneCallScenario({
|
||||
callee,
|
||||
message: message || null,
|
||||
voiceAvailable: Boolean(process.env.ELEVENLABS_API_KEY?.trim()),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ scenario },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to prepare the mock phone call.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
loadGitHubDashboard,
|
||||
loadGitHubPullRequestDetail,
|
||||
submitGitHubInlineComment,
|
||||
submitGitHubPullRequestReview,
|
||||
type GitHubInlineCommentSide,
|
||||
type GitHubReviewAction,
|
||||
} from "@/lib/office/github";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ReviewRequestBody = {
|
||||
repo?: string;
|
||||
number?: number;
|
||||
action?: GitHubReviewAction;
|
||||
body?: string | null;
|
||||
path?: string;
|
||||
line?: number;
|
||||
side?: GitHubInlineCommentSide;
|
||||
commitId?: string | null;
|
||||
};
|
||||
|
||||
const parsePullRequestNumber = (value: string | null): number | null => {
|
||||
if (!value) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return Math.round(parsed);
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const repo = (searchParams.get("repo") ?? "").trim();
|
||||
const number = parsePullRequestNumber(searchParams.get("number"));
|
||||
if (repo && number) {
|
||||
return NextResponse.json(loadGitHubPullRequestDetail({ repo, number }), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(loadGitHubDashboard(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load GitHub server room data.";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as ReviewRequestBody;
|
||||
const repo = typeof body.repo === "string" ? body.repo.trim() : "";
|
||||
const number =
|
||||
typeof body.number === "number" && Number.isFinite(body.number)
|
||||
? Math.round(body.number)
|
||||
: null;
|
||||
const action = typeof body.action === "string" ? body.action : null;
|
||||
const path = typeof body.path === "string" ? body.path.trim() : "";
|
||||
const line =
|
||||
typeof body.line === "number" && Number.isFinite(body.line)
|
||||
? Math.round(body.line)
|
||||
: null;
|
||||
const side = body.side === "LEFT" || body.side === "RIGHT" ? body.side : null;
|
||||
const commentBody = typeof body.body === "string" ? body.body : null;
|
||||
|
||||
if (action) {
|
||||
if (!repo || !number) {
|
||||
return NextResponse.json(
|
||||
{ error: "repo, number, and action are required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unsupported review action." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = submitGitHubPullRequestReview({
|
||||
repo,
|
||||
number,
|
||||
action,
|
||||
body: commentBody,
|
||||
});
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!repo || !number || !path || !line || !side || !commentBody?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "repo, number, path, line, side, and body are required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = submitGitHubInlineComment({
|
||||
repo,
|
||||
number,
|
||||
path,
|
||||
line,
|
||||
side,
|
||||
body: commentBody,
|
||||
commitId: typeof body.commitId === "string" ? body.commitId : null,
|
||||
});
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to submit GitHub review.";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { loadOfficePresenceSnapshot } from "@/lib/office/presence";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const workspaceId = url.searchParams.get("workspaceId")?.trim() || "default";
|
||||
const snapshot = loadOfficePresenceSnapshot(workspaceId);
|
||||
return NextResponse.json(snapshot);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load office presence.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listOfficeVersions, publishOfficeVersion } from "@/lib/office/store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const asString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const workspaceId = asString(body.workspaceId) || "default";
|
||||
const officeId = asString(body.officeId);
|
||||
const officeVersionId = asString(body.officeVersionId);
|
||||
const publishedBy = asString(body.publishedBy) || "studio";
|
||||
if (!workspaceId || !officeId) {
|
||||
return NextResponse.json({ error: "workspaceId and officeId are required." }, { status: 400 });
|
||||
}
|
||||
let selectedVersionId = officeVersionId;
|
||||
if (!selectedVersionId) {
|
||||
const versions = listOfficeVersions(workspaceId, officeId);
|
||||
selectedVersionId = versions[0]?.id ?? "";
|
||||
}
|
||||
if (!selectedVersionId) {
|
||||
return NextResponse.json({ error: "No office version available to publish." }, { status: 400 });
|
||||
}
|
||||
const published = publishOfficeVersion({
|
||||
workspaceId,
|
||||
officeId,
|
||||
officeVersionId: selectedVersionId,
|
||||
publishedBy,
|
||||
});
|
||||
return NextResponse.json({ published });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to publish office version.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createEmptyOfficeMap, normalizeOfficeMap, type OfficeMap } from "@/lib/office/schema";
|
||||
import {
|
||||
getPublishedOffice,
|
||||
getPublishedOfficeMap,
|
||||
listOfficesForWorkspace,
|
||||
listOfficeVersions,
|
||||
saveOfficeVersion,
|
||||
upsertOffice,
|
||||
} from "@/lib/office/store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const asString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const workspaceId = asString(url.searchParams.get("workspaceId")) || "default";
|
||||
const officeId = asString(url.searchParams.get("officeId"));
|
||||
const offices = listOfficesForWorkspace(workspaceId);
|
||||
const published = getPublishedOffice(workspaceId);
|
||||
const publishedMap = getPublishedOfficeMap(workspaceId);
|
||||
const versions = officeId ? listOfficeVersions(workspaceId, officeId) : [];
|
||||
return NextResponse.json({
|
||||
workspaceId,
|
||||
offices,
|
||||
versions,
|
||||
published,
|
||||
publishedMap,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load office data.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const action = asString(body.action);
|
||||
if (action === "upsertOffice") {
|
||||
const workspaceId = asString(body.workspaceId) || "default";
|
||||
const officeId = asString(body.officeId);
|
||||
const name = asString(body.name);
|
||||
if (!officeId || !name) {
|
||||
return NextResponse.json({ error: "officeId and name are required." }, { status: 400 });
|
||||
}
|
||||
const office = upsertOffice({
|
||||
workspaceId,
|
||||
officeId,
|
||||
name,
|
||||
});
|
||||
return NextResponse.json({ office });
|
||||
}
|
||||
if (action === "saveVersion") {
|
||||
const workspaceId = asString(body.workspaceId) || "default";
|
||||
const officeId = asString(body.officeId);
|
||||
const versionId = asString(body.versionId);
|
||||
const createdBy = asString(body.createdBy) || "studio";
|
||||
if (!officeId || !versionId) {
|
||||
return NextResponse.json({ error: "officeId and versionId are required." }, { status: 400 });
|
||||
}
|
||||
const incomingMap = body.map as OfficeMap | undefined;
|
||||
const fallback = createEmptyOfficeMap({
|
||||
workspaceId,
|
||||
officeVersionId: versionId,
|
||||
width: 1600,
|
||||
height: 900,
|
||||
});
|
||||
const map = normalizeOfficeMap(incomingMap, fallback);
|
||||
const record = saveOfficeVersion({
|
||||
workspaceId,
|
||||
officeId,
|
||||
versionId,
|
||||
createdBy,
|
||||
notes: asString(body.notes),
|
||||
map,
|
||||
});
|
||||
return NextResponse.json({ version: record });
|
||||
}
|
||||
return NextResponse.json({ error: "Unsupported office action." }, { status: 400 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save office data.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
applyStudioSettingsPatch,
|
||||
loadStudioSettings,
|
||||
} from "@/lib/studio/settings-store";
|
||||
import {
|
||||
resolveStandupPreference,
|
||||
sanitizeStandupPreference,
|
||||
type StudioStandupPreferencePatch,
|
||||
} from "@/lib/studio/settings";
|
||||
import { validateJiraBaseUrl } from "@/lib/security/urlSafety";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const readGatewayUrl = (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
return (url.searchParams.get("gatewayUrl") ?? "").trim();
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const gatewayUrl = readGatewayUrl(request);
|
||||
if (!gatewayUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "gatewayUrl is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const settings = loadStudioSettings();
|
||||
const config = resolveStandupPreference(settings, gatewayUrl);
|
||||
return NextResponse.json({ gatewayUrl, config: sanitizeStandupPreference(config) });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load standup config.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
gatewayUrl?: string;
|
||||
config?: StudioStandupPreferencePatch;
|
||||
};
|
||||
const gatewayUrl = typeof body.gatewayUrl === "string" ? body.gatewayUrl.trim() : "";
|
||||
if (!gatewayUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "gatewayUrl is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!body.config || typeof body.config !== "object") {
|
||||
return NextResponse.json({ error: "config is required." }, { status: 400 });
|
||||
}
|
||||
if (body.config.jira?.baseUrl?.trim()) {
|
||||
validateJiraBaseUrl(body.config.jira.baseUrl);
|
||||
}
|
||||
const settings = applyStudioSettingsPatch({
|
||||
standup: {
|
||||
[gatewayUrl]: body.config,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({
|
||||
gatewayUrl,
|
||||
config: sanitizeStandupPreference(resolveStandupPreference(settings, gatewayUrl)),
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to save standup config.";
|
||||
const status =
|
||||
message.includes("gatewayUrl is required") ||
|
||||
message.includes("config is required") ||
|
||||
message.includes("Jira base URL")
|
||||
? 400
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
advanceStandupMeeting,
|
||||
startStandupSpeaker,
|
||||
updateStandupArrivals,
|
||||
} from "@/lib/office/standup/service";
|
||||
import {
|
||||
loadActiveStandupMeeting,
|
||||
updateStandupMeeting,
|
||||
} from "@/lib/office/standup/store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(
|
||||
{ meeting: loadActiveStandupMeeting() },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load standup meeting.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
action?: "arrivals" | "start" | "advance" | "complete";
|
||||
arrivedAgentIds?: string[];
|
||||
speakerAgentId?: string | null;
|
||||
};
|
||||
const action = typeof body.action === "string" ? body.action : "";
|
||||
if (!action) {
|
||||
return NextResponse.json({ error: "action is required." }, { status: 400 });
|
||||
}
|
||||
const store = updateStandupMeeting((meeting) => {
|
||||
if (!meeting) return null;
|
||||
if (action === "arrivals") {
|
||||
return updateStandupArrivals(meeting, body.arrivedAgentIds ?? []);
|
||||
}
|
||||
if (action === "start") {
|
||||
const speakerAgentId =
|
||||
typeof body.speakerAgentId === "string" ? body.speakerAgentId.trim() : null;
|
||||
return startStandupSpeaker(meeting, speakerAgentId);
|
||||
}
|
||||
if (action === "advance") {
|
||||
return advanceStandupMeeting(meeting);
|
||||
}
|
||||
if (action === "complete") {
|
||||
return startStandupSpeaker(meeting, null);
|
||||
}
|
||||
return meeting;
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ meeting: store.activeMeeting },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to update standup meeting.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { buildStandupMeeting } from "@/lib/office/standup/service";
|
||||
import { saveStandupMeeting } from "@/lib/office/standup/store";
|
||||
import type { StandupAgentSnapshot, StandupTriggerKind } from "@/lib/office/standup/types";
|
||||
import {
|
||||
applyStudioSettingsPatch,
|
||||
loadStudioSettings,
|
||||
} from "@/lib/studio/settings-store";
|
||||
import { resolveStandupPreference } from "@/lib/studio/settings";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
gatewayUrl?: string;
|
||||
agents?: StandupAgentSnapshot[];
|
||||
trigger?: StandupTriggerKind;
|
||||
scheduledFor?: string | null;
|
||||
};
|
||||
const gatewayUrl =
|
||||
typeof body.gatewayUrl === "string" ? body.gatewayUrl.trim() : "";
|
||||
if (!gatewayUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "gatewayUrl is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const settings = loadStudioSettings();
|
||||
const config = resolveStandupPreference(settings, gatewayUrl);
|
||||
const trigger = body.trigger === "scheduled" ? "scheduled" : "manual";
|
||||
const meeting = await buildStandupMeeting({
|
||||
config,
|
||||
agents: Array.isArray(body.agents) ? body.agents : [],
|
||||
trigger,
|
||||
scheduledFor:
|
||||
typeof body.scheduledFor === "string" ? body.scheduledFor : null,
|
||||
});
|
||||
saveStandupMeeting(meeting);
|
||||
if (trigger === "scheduled") {
|
||||
applyStudioSettingsPatch({
|
||||
standup: {
|
||||
[gatewayUrl]: {
|
||||
schedule: {
|
||||
lastAutoRunAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ meeting },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to start standup meeting.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { buildMockTextMessageScenario } from "@/lib/office/text/mock";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type TextMessageRequestBody = {
|
||||
recipient?: string;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
const MAX_RECIPIENT_CHARS = 120;
|
||||
const MAX_MESSAGE_CHARS = 1_000;
|
||||
|
||||
const normalizeText = (value: string | null | undefined): string =>
|
||||
(value ?? "").replace(/\s+/g, " ").trim();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as TextMessageRequestBody;
|
||||
const recipient = normalizeText(body.recipient);
|
||||
const message = normalizeText(body.message);
|
||||
|
||||
if (!recipient) {
|
||||
return NextResponse.json({ error: "recipient is required." }, { status: 400 });
|
||||
}
|
||||
if (recipient.length > MAX_RECIPIENT_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `recipient exceeds ${MAX_RECIPIENT_CHARS} characters.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (message.length > MAX_MESSAGE_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `message exceeds ${MAX_MESSAGE_CHARS} characters.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Create Claw3D voice and text skill.
|
||||
const scenario = buildMockTextMessageScenario({
|
||||
recipient,
|
||||
message: message || null,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ scenario },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to prepare the mock text message.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { synthesizeVoiceReply, type VoiceReplyProvider } from "@/lib/voiceReply/provider";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type VoiceReplyRequestBody = {
|
||||
text?: string;
|
||||
provider?: VoiceReplyProvider;
|
||||
voiceId?: string | null;
|
||||
speed?: number;
|
||||
};
|
||||
|
||||
const MAX_REPLY_CHARS = 5_000;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as VoiceReplyRequestBody;
|
||||
const text = typeof body.text === "string" ? body.text.replace(/\s+/g, " ").trim() : "";
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: "Voice reply text is required." }, { status: 400 });
|
||||
}
|
||||
if (text.length > MAX_REPLY_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `Voice reply text exceeds ${MAX_REPLY_CHARS} characters.` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const response = await synthesizeVoiceReply({
|
||||
text,
|
||||
provider: body.provider,
|
||||
voiceId: body.voiceId,
|
||||
speed: body.speed,
|
||||
});
|
||||
return new Response(response.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": response.headers.get("content-type") ?? "audio/mpeg",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to synthesize the voice reply.";
|
||||
const status = message.includes("Missing ELEVENLABS_API_KEY") ? 503 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { transcribeVoiceWithOpenClaw } from "@/lib/openclaw/voiceTranscription";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_VOICE_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const audio = formData.get("audio");
|
||||
if (!(audio instanceof File)) {
|
||||
return NextResponse.json({ error: "audio file is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await audio.arrayBuffer();
|
||||
const byteLength = arrayBuffer.byteLength;
|
||||
if (byteLength <= 0) {
|
||||
return NextResponse.json({ error: "Audio upload is empty." }, { status: 400 });
|
||||
}
|
||||
if (byteLength > MAX_VOICE_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Audio upload exceeds the ${MAX_VOICE_UPLOAD_BYTES} byte limit.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await transcribeVoiceWithOpenClaw({
|
||||
buffer: Buffer.from(arrayBuffer),
|
||||
fileName: audio.name,
|
||||
mimeType: audio.type,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
transcript: result.transcript,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
decision: result.decision,
|
||||
ignored: result.ignored,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to transcribe audio.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveUserPath } from "@/lib/clawdbot/paths";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type PathAutocompleteEntry = {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
displayPath: string;
|
||||
isDirectory: boolean;
|
||||
};
|
||||
|
||||
type PathAutocompleteResult = {
|
||||
query: string;
|
||||
directory: string;
|
||||
entries: PathAutocompleteEntry[];
|
||||
};
|
||||
|
||||
type PathAutocompleteOptions = {
|
||||
query: string;
|
||||
maxResults?: number;
|
||||
homedir?: () => string;
|
||||
};
|
||||
|
||||
const normalizeQuery = (query: string): string => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Query is required.");
|
||||
}
|
||||
if (trimmed === "~") {
|
||||
return "~/";
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
return trimmed;
|
||||
}
|
||||
const withoutLeading = trimmed.replace(/^[\\/]+/, "");
|
||||
return `~/${withoutLeading}`;
|
||||
};
|
||||
|
||||
const isWithinHome = (target: string, home: string): boolean => {
|
||||
const relative = path.relative(home, target);
|
||||
if (!relative) return true;
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
};
|
||||
|
||||
const listPathAutocompleteEntries = ({
|
||||
query,
|
||||
maxResults = 10,
|
||||
homedir = os.homedir,
|
||||
}: PathAutocompleteOptions): PathAutocompleteResult => {
|
||||
const normalized = normalizeQuery(query);
|
||||
const resolvedHome = path.resolve(homedir());
|
||||
const resolvedQuery = resolveUserPath(normalized, homedir);
|
||||
if (!isWithinHome(resolvedQuery, resolvedHome)) {
|
||||
throw new Error("Path must stay within the home directory.");
|
||||
}
|
||||
|
||||
const endsWithSlash = normalized.endsWith("/") || normalized.endsWith(path.sep);
|
||||
const directoryPath = endsWithSlash ? resolvedQuery : path.dirname(resolvedQuery);
|
||||
const prefix = endsWithSlash ? "" : path.basename(resolvedQuery);
|
||||
|
||||
if (!isWithinHome(directoryPath, resolvedHome)) {
|
||||
throw new Error("Path must stay within the home directory.");
|
||||
}
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
throw new Error(`Directory does not exist: ${directoryPath}`);
|
||||
}
|
||||
const stat = fs.statSync(directoryPath);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${directoryPath}`);
|
||||
}
|
||||
|
||||
const limit = Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : 10;
|
||||
|
||||
const entries = fs
|
||||
.readdirSync(directoryPath, { withFileTypes: true })
|
||||
.filter((entry) => !entry.name.startsWith("."))
|
||||
.filter((entry) => entry.name.startsWith(prefix))
|
||||
.map((entry) => {
|
||||
const fullPath = path.join(directoryPath, entry.name);
|
||||
const relative = path.relative(resolvedHome, fullPath);
|
||||
const normalizedRelative = relative.split(path.sep).join("/");
|
||||
const displayBase = `~/${normalizedRelative}`;
|
||||
return {
|
||||
name: entry.name,
|
||||
fullPath,
|
||||
displayPath: entry.isDirectory() ? `${displayBase}/` : displayBase,
|
||||
isDirectory: entry.isDirectory(),
|
||||
} satisfies PathAutocompleteEntry;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return { query: normalized, directory: directoryPath, entries };
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawQuery = searchParams.get("q");
|
||||
const query = rawQuery && rawQuery.trim() ? rawQuery.trim() : "~/";
|
||||
const result = listPathAutocompleteEntries({ query, maxResults: 10 });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to list path suggestions.";
|
||||
console.error(message);
|
||||
const status = message.includes("does not exist") ? 404 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
sanitizeStudioGatewaySettings,
|
||||
sanitizeStudioSettings,
|
||||
type StudioSettingsPatch,
|
||||
} from "@/lib/studio/settings";
|
||||
import {
|
||||
applyStudioSettingsPatch,
|
||||
loadLocalGatewayDefaults,
|
||||
loadStudioSettings,
|
||||
} from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const isPatch = (value: unknown): value is StudioSettingsPatch =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = loadStudioSettings();
|
||||
const localGatewayDefaults = loadLocalGatewayDefaults();
|
||||
return NextResponse.json(
|
||||
{
|
||||
settings: sanitizeStudioSettings(settings),
|
||||
localGatewayDefaults: sanitizeStudioGatewaySettings(localGatewayDefaults),
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load studio settings.";
|
||||
console.error(message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (!isPatch(body)) {
|
||||
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
||||
}
|
||||
const settings = applyStudioSettingsPatch(body);
|
||||
return NextResponse.json(
|
||||
{ settings: sanitizeStudioSettings(settings) },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to save studio settings.";
|
||||
console.error(message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+1355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Bebas_Neue, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Claw3D",
|
||||
description: "Focused operator studio for the OpenClaw gateway.",
|
||||
};
|
||||
|
||||
const display = Bebas_Neue({
|
||||
variable: "--font-display",
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const sans = IBM_Plex_Sans({
|
||||
variable: "--font-sans",
|
||||
weight: ["400", "500", "600", "700"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const mono = IBM_Plex_Mono({
|
||||
variable: "--font-mono",
|
||||
weight: ["400", "500", "600"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var t=localStorage.getItem('theme');var m=window.matchMedia('(prefers-color-scheme: dark)').matches;var d=t?t==='dark':m;document.documentElement.classList.toggle('dark',d);}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${display.variable} ${sans.variable} ${mono.variable} antialiased`}>
|
||||
<main className="h-screen w-screen overflow-hidden bg-background">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { OfficeBuilderPanel } from "@/features/office/components/OfficeBuilderPanel";
|
||||
import { createStarterOfficeMap, normalizeOfficeMap } from "@/lib/office/schema";
|
||||
import { getPublishedOfficeMap } from "@/lib/office/store";
|
||||
|
||||
const WORKSPACE_ID = "default";
|
||||
const OFFICE_ID = "hq";
|
||||
|
||||
export default function OfficeBuilderPage() {
|
||||
const fallback = createStarterOfficeMap({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
officeVersionId: "builder-draft",
|
||||
width: 1600,
|
||||
height: 900,
|
||||
});
|
||||
const map = normalizeOfficeMap(getPublishedOfficeMap(WORKSPACE_ID), fallback);
|
||||
return (
|
||||
<main className="relative h-screen w-screen overflow-hidden bg-background p-3">
|
||||
<OfficeBuilderPanel initialMap={map} workspaceId={WORKSPACE_ID} officeId={OFFICE_ID} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Suspense } from "react";
|
||||
import { AgentStoreProvider } from "@/features/agents/state/store";
|
||||
import { OfficeScreen } from "@/features/office/screens/OfficeScreen";
|
||||
|
||||
export default function OfficePage() {
|
||||
return (
|
||||
<AgentStoreProvider>
|
||||
<Suspense fallback={null}>
|
||||
<OfficeScreen />
|
||||
</Suspense>
|
||||
</AgentStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/office");
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
.agent-markdown {
|
||||
display: block;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.agent-markdown p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-markdown p + p,
|
||||
.agent-markdown ul,
|
||||
.agent-markdown ol,
|
||||
.agent-markdown pre,
|
||||
.agent-markdown blockquote,
|
||||
.agent-markdown table {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.agent-markdown h1,
|
||||
.agent-markdown h2,
|
||||
.agent-markdown h3,
|
||||
.agent-markdown h4,
|
||||
.agent-markdown h5,
|
||||
.agent-markdown h6 {
|
||||
margin: 1.1rem 0 0.5rem 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.agent-markdown :is(h1, h2, h3, h4, h5, h6):first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.agent-markdown h1 + p,
|
||||
.agent-markdown h2 + p,
|
||||
.agent-markdown h3 + p,
|
||||
.agent-markdown h4 + p,
|
||||
.agent-markdown h5 + p,
|
||||
.agent-markdown h6 + p,
|
||||
.agent-markdown h1 + ul,
|
||||
.agent-markdown h2 + ul,
|
||||
.agent-markdown h3 + ul,
|
||||
.agent-markdown h4 + ul,
|
||||
.agent-markdown h5 + ul,
|
||||
.agent-markdown h6 + ul,
|
||||
.agent-markdown h1 + ol,
|
||||
.agent-markdown h2 + ol,
|
||||
.agent-markdown h3 + ol,
|
||||
.agent-markdown h4 + ol,
|
||||
.agent-markdown h5 + ol,
|
||||
.agent-markdown h6 + ol {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.agent-markdown ul,
|
||||
.agent-markdown ol {
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.agent-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.agent-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.agent-markdown ul ul {
|
||||
list-style-type: circle;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.agent-markdown ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.agent-markdown ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
.agent-markdown ul ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.agent-markdown li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.agent-markdown li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-markdown code {
|
||||
font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.82em;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
background: color-mix(in oklch, var(--surface-3) 92%, transparent);
|
||||
padding: 0.14rem 0.28rem;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1px solid color-mix(in oklch, var(--surface-selected-border) 88%, transparent);
|
||||
}
|
||||
|
||||
.agent-markdown pre {
|
||||
background: color-mix(in oklch, var(--surface-2) 94%, transparent);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid color-mix(in oklch, var(--surface-selected-border) 82%, transparent);
|
||||
overflow-x: auto;
|
||||
line-height: 1.64;
|
||||
}
|
||||
|
||||
.agent-markdown pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.agent-tool-markdown pre {
|
||||
max-height: 70px;
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.agent-markdown blockquote {
|
||||
border-left: 3px solid var(--surface-selected-border);
|
||||
padding-left: 0.7rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.agent-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.agent-markdown th,
|
||||
.agent-markdown td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.45rem 0.65rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-markdown a {
|
||||
color: color-mix(in oklch, var(--action-bg) 68%, var(--foreground));
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.agent-markdown img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dark .agent-markdown code {
|
||||
background: color-mix(in oklch, var(--surface-1) 80%, var(--surface-0));
|
||||
border-color: color-mix(in oklch, var(--chat-assistant-border) 88%, var(--surface-selected-border));
|
||||
}
|
||||
|
||||
.dark .agent-markdown pre {
|
||||
background: color-mix(in oklch, var(--surface-0) 82%, var(--surface-1));
|
||||
border-color: color-mix(in oklch, var(--chat-assistant-border) 86%, transparent);
|
||||
box-shadow: inset 0 1px 0 var(--elev-overlay-1);
|
||||
}
|
||||
|
||||
.dark .ui-chat-assistant-card .agent-markdown :is(p + p, ul, ol, pre, blockquote, table) {
|
||||
margin-top: 1.35rem;
|
||||
}
|
||||
|
||||
.dark .ui-chat-assistant-card .agent-markdown :is(h1, h2, h3, h4, h5, h6) {
|
||||
margin: 1.35rem 0 0.68rem 0;
|
||||
}
|
||||
|
||||
.dark .ui-chat-assistant-card .agent-markdown li + li {
|
||||
margin-top: 0.42rem;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
const THEME_STORAGE_KEY = "theme";
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
|
||||
const getPreferredTheme = (): ThemeMode => {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
return prefersDark ? "dark" : "light";
|
||||
};
|
||||
|
||||
const applyTheme = (mode: ThemeMode) => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.classList.toggle("dark", mode === "dark");
|
||||
};
|
||||
|
||||
export const ThemeToggle = () => {
|
||||
// Keep SSR + initial hydration stable ("light") to avoid markup mismatch.
|
||||
const [theme, setTheme] = useState<ThemeMode>("light");
|
||||
|
||||
useEffect(() => {
|
||||
const preferred = getPreferredTheme();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTheme(preferred);
|
||||
applyTheme(preferred);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((current) => {
|
||||
const next: ThemeMode = current === "dark" ? "light" : "dark";
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, next);
|
||||
}
|
||||
applyTheme(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="ui-btn-icon ui-btn-icon-xs"
|
||||
>
|
||||
{isDark ? <Sun className="h-3 w-3" /> : <Moon className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
applyApprovalIngressEffects,
|
||||
deriveAwaitingUserInputPatches,
|
||||
derivePendingApprovalPruneDelayMs,
|
||||
prunePendingApprovalState,
|
||||
resolveApprovalAutoResumeDispatch,
|
||||
resolveApprovalAutoResumePreflight,
|
||||
type ApprovalPendingState,
|
||||
type AwaitingUserInputPatch,
|
||||
} from "@/features/agents/approvals/execApprovalRuntimeCoordinator";
|
||||
import { shouldPauseRunForPendingExecApproval } from "@/features/agents/approvals/execApprovalPausePolicy";
|
||||
import {
|
||||
resolveGatewayEventIngressDecision,
|
||||
type CronTranscriptIntent,
|
||||
} from "@/features/agents/state/gatewayEventIngressWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export type ExecApprovalPendingSnapshot = ApprovalPendingState;
|
||||
|
||||
export type ExecApprovalIngressCommand =
|
||||
| { kind: "replacePendingState"; pendingState: ApprovalPendingState }
|
||||
| {
|
||||
kind: "pauseRunForApproval";
|
||||
approval: PendingExecApproval;
|
||||
preferredAgentId: string | null;
|
||||
}
|
||||
| { kind: "markActivity"; agentId: string }
|
||||
| { kind: "recordCronDedupeKey"; dedupeKey: string }
|
||||
| { kind: "appendCronTranscript"; intent: CronTranscriptIntent };
|
||||
|
||||
export type PauseRunIntent =
|
||||
| { kind: "skip"; reason: string }
|
||||
| { kind: "pause"; agentId: string; sessionKey: string; runId: string };
|
||||
|
||||
export type AutoResumeIntent =
|
||||
| { kind: "skip"; reason: string }
|
||||
| { kind: "resume"; targetAgentId: string; pausedRunId: string; sessionKey: string };
|
||||
|
||||
const resolvePauseTargetAgent = (params: {
|
||||
approval: PendingExecApproval;
|
||||
preferredAgentId: string | null | undefined;
|
||||
agents: AgentState[];
|
||||
}): AgentState | null => {
|
||||
const preferredAgentId = params.preferredAgentId?.trim() ?? "";
|
||||
if (preferredAgentId) {
|
||||
const match =
|
||||
params.agents.find((agent) => agent.agentId === preferredAgentId) ?? null;
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
const approvalSessionKey = params.approval.sessionKey?.trim() ?? "";
|
||||
if (!approvalSessionKey) return null;
|
||||
|
||||
return (
|
||||
params.agents.find((agent) => agent.sessionKey.trim() === approvalSessionKey) ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
export const planPausedRunMapCleanup = (params: {
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
agents: AgentState[];
|
||||
}): string[] => {
|
||||
const staleAgentIds: string[] = [];
|
||||
for (const [agentId, trackedRunId] of params.pausedRunIdByAgentId.entries()) {
|
||||
const trackedAgent = params.agents.find((agent) => agent.agentId === agentId) ?? null;
|
||||
const currentRunId = trackedAgent?.runId?.trim() ?? "";
|
||||
if (!currentRunId || currentRunId !== trackedRunId) {
|
||||
staleAgentIds.push(agentId);
|
||||
}
|
||||
}
|
||||
return staleAgentIds;
|
||||
};
|
||||
|
||||
export const planPauseRunIntent = (params: {
|
||||
approval: PendingExecApproval;
|
||||
preferredAgentId?: string | null;
|
||||
agents: AgentState[];
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
}): PauseRunIntent => {
|
||||
const agent = resolvePauseTargetAgent({
|
||||
approval: params.approval,
|
||||
preferredAgentId: params.preferredAgentId,
|
||||
agents: params.agents,
|
||||
});
|
||||
if (!agent) {
|
||||
return { kind: "skip", reason: "missing-agent" };
|
||||
}
|
||||
|
||||
const runId = agent.runId?.trim() ?? "";
|
||||
if (!runId) {
|
||||
return { kind: "skip", reason: "missing-run-id" };
|
||||
}
|
||||
|
||||
const pausedRunId = params.pausedRunIdByAgentId.get(agent.agentId) ?? null;
|
||||
const shouldPause = shouldPauseRunForPendingExecApproval({
|
||||
agent,
|
||||
approval: params.approval,
|
||||
pausedRunId,
|
||||
});
|
||||
if (!shouldPause) {
|
||||
return { kind: "skip", reason: "pause-policy-denied" };
|
||||
}
|
||||
|
||||
const sessionKey = agent.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return { kind: "skip", reason: "missing-session-key" };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "pause",
|
||||
agentId: agent.agentId,
|
||||
sessionKey,
|
||||
runId,
|
||||
};
|
||||
};
|
||||
|
||||
export const planAutoResumeIntent = (params: {
|
||||
approval: PendingExecApproval;
|
||||
targetAgentId: string;
|
||||
pendingState: ApprovalPendingState;
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
agents: AgentState[];
|
||||
}): AutoResumeIntent => {
|
||||
const preflight = resolveApprovalAutoResumePreflight({
|
||||
approval: params.approval,
|
||||
targetAgentId: params.targetAgentId,
|
||||
pendingState: params.pendingState,
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
});
|
||||
|
||||
if (preflight.kind !== "resume") {
|
||||
return { kind: "skip", reason: preflight.reason };
|
||||
}
|
||||
|
||||
const dispatchIntent = resolveApprovalAutoResumeDispatch({
|
||||
targetAgentId: preflight.targetAgentId,
|
||||
pausedRunId: preflight.pausedRunId,
|
||||
agents: params.agents,
|
||||
});
|
||||
|
||||
if (dispatchIntent.kind !== "resume") {
|
||||
return { kind: "skip", reason: dispatchIntent.reason };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "resume",
|
||||
targetAgentId: dispatchIntent.targetAgentId,
|
||||
pausedRunId: dispatchIntent.pausedRunId,
|
||||
sessionKey: dispatchIntent.sessionKey,
|
||||
};
|
||||
};
|
||||
|
||||
export const planIngressCommands = (params: {
|
||||
event: EventFrame;
|
||||
agents: AgentState[];
|
||||
pendingState: ApprovalPendingState;
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
seenCronDedupeKeys: ReadonlySet<string>;
|
||||
nowMs: number;
|
||||
}): ExecApprovalIngressCommand[] => {
|
||||
const ingressDecision = resolveGatewayEventIngressDecision({
|
||||
event: params.event,
|
||||
agents: params.agents,
|
||||
seenCronDedupeKeys: params.seenCronDedupeKeys,
|
||||
nowMs: params.nowMs,
|
||||
});
|
||||
|
||||
const approvalIngress = applyApprovalIngressEffects({
|
||||
pendingState: params.pendingState,
|
||||
approvalEffects: ingressDecision.approvalEffects,
|
||||
agents: params.agents,
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
});
|
||||
|
||||
const commands: ExecApprovalIngressCommand[] = [];
|
||||
if (
|
||||
approvalIngress.pendingState.approvalsByAgentId !== params.pendingState.approvalsByAgentId ||
|
||||
approvalIngress.pendingState.unscopedApprovals !== params.pendingState.unscopedApprovals
|
||||
) {
|
||||
commands.push({
|
||||
kind: "replacePendingState",
|
||||
pendingState: approvalIngress.pendingState,
|
||||
});
|
||||
}
|
||||
|
||||
for (const pauseRequest of approvalIngress.pauseRequests) {
|
||||
commands.push({
|
||||
kind: "pauseRunForApproval",
|
||||
approval: pauseRequest.approval,
|
||||
preferredAgentId: pauseRequest.preferredAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const agentId of approvalIngress.markActivityAgentIds) {
|
||||
commands.push({ kind: "markActivity", agentId });
|
||||
}
|
||||
|
||||
if (ingressDecision.cronDedupeKeyToRecord) {
|
||||
commands.push({
|
||||
kind: "recordCronDedupeKey",
|
||||
dedupeKey: ingressDecision.cronDedupeKeyToRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (ingressDecision.cronTranscriptIntent) {
|
||||
commands.push({
|
||||
kind: "appendCronTranscript",
|
||||
intent: ingressDecision.cronTranscriptIntent,
|
||||
});
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
|
||||
export const planPendingPruneDelay = (params: {
|
||||
pendingState: ApprovalPendingState;
|
||||
nowMs: number;
|
||||
graceMs: number;
|
||||
}): number | null => {
|
||||
return derivePendingApprovalPruneDelayMs(params);
|
||||
};
|
||||
|
||||
export const planPrunedPendingState = (params: {
|
||||
pendingState: ApprovalPendingState;
|
||||
nowMs: number;
|
||||
graceMs: number;
|
||||
}): ApprovalPendingState => {
|
||||
return prunePendingApprovalState(params).pendingState;
|
||||
};
|
||||
|
||||
export const planAwaitingUserInputPatches = (params: {
|
||||
agents: AgentState[];
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||
}): AwaitingUserInputPatch[] => {
|
||||
return deriveAwaitingUserInputPatches(params);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
import type { ExecApprovalDecision } from "@/features/agents/approvals/types";
|
||||
|
||||
type RequestedPayload = {
|
||||
id: string;
|
||||
request: {
|
||||
command: string;
|
||||
cwd: string | null;
|
||||
host: string | null;
|
||||
security: string | null;
|
||||
ask: string | null;
|
||||
agentId: string | null;
|
||||
resolvedPath: string | null;
|
||||
sessionKey: string | null;
|
||||
};
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
type ResolvedPayload = {
|
||||
id: string;
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy: string | null;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const asNonEmptyString = (value: unknown): string | null => {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const asOptionalString = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
|
||||
const asPositiveTimestamp = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
|
||||
export const parseExecApprovalRequested = (event: EventFrame): RequestedPayload | null => {
|
||||
if (event.type !== "event" || event.event !== "exec.approval.requested") return null;
|
||||
const payload = asRecord(event.payload);
|
||||
if (!payload) return null;
|
||||
const id = asNonEmptyString(payload.id);
|
||||
const request = asRecord(payload.request);
|
||||
const createdAtMs = asPositiveTimestamp(payload.createdAtMs);
|
||||
const expiresAtMs = asPositiveTimestamp(payload.expiresAtMs);
|
||||
if (!id || !request || !createdAtMs || !expiresAtMs) return null;
|
||||
const command = asNonEmptyString(request.command);
|
||||
if (!command) return null;
|
||||
return {
|
||||
id,
|
||||
request: {
|
||||
command,
|
||||
cwd: asOptionalString(request.cwd),
|
||||
host: asOptionalString(request.host),
|
||||
security: asOptionalString(request.security),
|
||||
ask: asOptionalString(request.ask),
|
||||
agentId: asOptionalString(request.agentId),
|
||||
resolvedPath: asOptionalString(request.resolvedPath),
|
||||
sessionKey: asOptionalString(request.sessionKey),
|
||||
},
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseExecApprovalResolved = (event: EventFrame): ResolvedPayload | null => {
|
||||
if (event.type !== "event" || event.event !== "exec.approval.resolved") return null;
|
||||
const payload = asRecord(event.payload);
|
||||
if (!payload) return null;
|
||||
const id = asNonEmptyString(payload.id);
|
||||
const decisionRaw = asNonEmptyString(payload.decision);
|
||||
const ts = asPositiveTimestamp(payload.ts);
|
||||
if (!id || !decisionRaw || !ts) return null;
|
||||
if (decisionRaw !== "allow-once" && decisionRaw !== "allow-always" && decisionRaw !== "deny") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
decision: decisionRaw,
|
||||
resolvedBy: asOptionalString(payload.resolvedBy),
|
||||
ts,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveExecApprovalAgentId = (params: {
|
||||
requested: RequestedPayload;
|
||||
agents: AgentState[];
|
||||
}): string | null => {
|
||||
const requestedAgentId = params.requested.request.agentId;
|
||||
if (requestedAgentId) {
|
||||
return requestedAgentId;
|
||||
}
|
||||
const requestedSessionKey = params.requested.request.sessionKey;
|
||||
if (!requestedSessionKey) return null;
|
||||
const matchedBySession = params.agents.find(
|
||||
(agent) => agent.sessionKey.trim() === requestedSessionKey
|
||||
);
|
||||
return matchedBySession?.agentId ?? null;
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
parseExecApprovalRequested,
|
||||
parseExecApprovalResolved,
|
||||
resolveExecApprovalAgentId,
|
||||
} from "@/features/agents/approvals/execApprovalEvents";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
|
||||
export type ExecApprovalEventEffects = {
|
||||
scopedUpserts: Array<{ agentId: string; approval: PendingExecApproval }>;
|
||||
unscopedUpserts: PendingExecApproval[];
|
||||
removals: string[];
|
||||
markActivityAgentIds: string[];
|
||||
};
|
||||
|
||||
export type ExecApprovalFollowUpIntent = {
|
||||
shouldSend: boolean;
|
||||
agentId: string | null;
|
||||
sessionKey: string | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
const EMPTY_EVENT_EFFECTS: ExecApprovalEventEffects = {
|
||||
scopedUpserts: [],
|
||||
unscopedUpserts: [],
|
||||
removals: [],
|
||||
markActivityAgentIds: [],
|
||||
};
|
||||
|
||||
const NO_FOLLOW_UP_INTENT: ExecApprovalFollowUpIntent = {
|
||||
shouldSend: false,
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
export const resolveExecApprovalEventEffects = (params: {
|
||||
event: EventFrame;
|
||||
agents: AgentState[];
|
||||
}): ExecApprovalEventEffects | null => {
|
||||
const requested = parseExecApprovalRequested(params.event);
|
||||
if (requested) {
|
||||
const resolvedAgentId = resolveExecApprovalAgentId({
|
||||
requested,
|
||||
agents: params.agents,
|
||||
});
|
||||
const approval: PendingExecApproval = {
|
||||
id: requested.id,
|
||||
agentId: resolvedAgentId,
|
||||
sessionKey: requested.request.sessionKey,
|
||||
command: requested.request.command,
|
||||
cwd: requested.request.cwd,
|
||||
host: requested.request.host,
|
||||
security: requested.request.security,
|
||||
ask: requested.request.ask,
|
||||
resolvedPath: requested.request.resolvedPath,
|
||||
createdAtMs: requested.createdAtMs,
|
||||
expiresAtMs: requested.expiresAtMs,
|
||||
resolving: false,
|
||||
error: null,
|
||||
};
|
||||
if (!resolvedAgentId) {
|
||||
return {
|
||||
...EMPTY_EVENT_EFFECTS,
|
||||
unscopedUpserts: [approval],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...EMPTY_EVENT_EFFECTS,
|
||||
scopedUpserts: [{ agentId: resolvedAgentId, approval }],
|
||||
markActivityAgentIds: [resolvedAgentId],
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = parseExecApprovalResolved(params.event);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...EMPTY_EVENT_EFFECTS,
|
||||
removals: [resolved.id],
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveExecApprovalFollowUpIntent = (params: {
|
||||
decision: ExecApprovalDecision;
|
||||
approval: PendingExecApproval | null;
|
||||
agents: AgentState[];
|
||||
followUpMessage: string;
|
||||
}): ExecApprovalFollowUpIntent => {
|
||||
if (params.decision !== "allow-once" && params.decision !== "allow-always") {
|
||||
return NO_FOLLOW_UP_INTENT;
|
||||
}
|
||||
if (!params.approval) {
|
||||
return NO_FOLLOW_UP_INTENT;
|
||||
}
|
||||
const scopedAgentId = params.approval.agentId?.trim() ?? "";
|
||||
const sessionAgentId =
|
||||
params.approval.sessionKey?.trim()
|
||||
? (params.agents.find(
|
||||
(agent) => agent.sessionKey.trim() === params.approval?.sessionKey?.trim()
|
||||
)?.agentId ?? "")
|
||||
: "";
|
||||
const targetAgentId = scopedAgentId || sessionAgentId;
|
||||
if (!targetAgentId) {
|
||||
return NO_FOLLOW_UP_INTENT;
|
||||
}
|
||||
const targetSessionKey =
|
||||
params.approval.sessionKey?.trim() ||
|
||||
params.agents.find((agent) => agent.agentId === targetAgentId)?.sessionKey?.trim() ||
|
||||
"";
|
||||
const followUpMessage = params.followUpMessage.trim();
|
||||
if (!targetSessionKey || !followUpMessage) {
|
||||
return NO_FOLLOW_UP_INTENT;
|
||||
}
|
||||
return {
|
||||
shouldSend: true,
|
||||
agentId: targetAgentId,
|
||||
sessionKey: targetSessionKey,
|
||||
message: followUpMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldTreatExecApprovalResolveErrorAsUnknownId = (error: unknown): boolean =>
|
||||
error instanceof GatewayResponseError && /unknown approval id/i.test(error.message);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
const normalizeExecAsk = (
|
||||
value: string | null | undefined
|
||||
): "off" | "on-miss" | "always" | null => {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const shouldPauseRunForPendingExecApproval = (params: {
|
||||
agent: AgentState | null;
|
||||
approval: PendingExecApproval;
|
||||
pausedRunId: string | null;
|
||||
}): boolean => {
|
||||
const agent = params.agent;
|
||||
if (!agent) return false;
|
||||
if (agent.status !== "running") return false;
|
||||
|
||||
const runId = agent.runId?.trim() ?? "";
|
||||
if (!runId) return false;
|
||||
if (params.pausedRunId === runId) return false;
|
||||
|
||||
const approvalAsk = normalizeExecAsk(params.approval.ask);
|
||||
const agentAsk = normalizeExecAsk(agent.sessionExecAsk);
|
||||
const effectiveAsk = approvalAsk ?? agentAsk;
|
||||
return effectiveAsk === "always";
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
removePendingApprovalEverywhere,
|
||||
updatePendingApprovalById,
|
||||
} from "@/features/agents/approvals/pendingStore";
|
||||
import { shouldTreatExecApprovalResolveErrorAsUnknownId } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type SetState<T> = (next: T | ((current: T) => T)) => void;
|
||||
|
||||
export const resolveExecApprovalViaStudio = async (params: {
|
||||
client: GatewayClientLike;
|
||||
approvalId: string;
|
||||
decision: ExecApprovalDecision;
|
||||
getAgents: () => AgentState[];
|
||||
getLatestAgent: (agentId: string) => AgentState | null;
|
||||
getPendingState: () => {
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||
unscopedApprovals: PendingExecApproval[];
|
||||
};
|
||||
setPendingExecApprovalsByAgentId: SetState<Record<string, PendingExecApproval[]>>;
|
||||
setUnscopedPendingExecApprovals: SetState<PendingExecApproval[]>;
|
||||
requestHistoryRefresh: (agentId: string) => Promise<void> | void;
|
||||
onAllowResolved?: (params: {
|
||||
approval: PendingExecApproval;
|
||||
targetAgentId: string;
|
||||
}) => Promise<void> | void;
|
||||
onAllowed?: (params: { approval: PendingExecApproval; targetAgentId: string }) => Promise<void> | void;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
shouldTreatUnknownId?: (error: unknown) => boolean;
|
||||
logWarn?: (message: string, error: unknown) => void;
|
||||
}): Promise<void> => {
|
||||
const id = params.approvalId.trim();
|
||||
if (!id) return;
|
||||
|
||||
const resolvePendingApproval = (
|
||||
approvalId: string,
|
||||
state: {
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||
unscopedApprovals: PendingExecApproval[];
|
||||
}
|
||||
): PendingExecApproval | null => {
|
||||
for (const approvals of Object.values(state.approvalsByAgentId)) {
|
||||
const found = approvals.find((approval) => approval.id === approvalId);
|
||||
if (found) return found;
|
||||
}
|
||||
return state.unscopedApprovals.find((approval) => approval.id === approvalId) ?? null;
|
||||
};
|
||||
|
||||
const resolveApprovalTargetAgentId = (approval: PendingExecApproval | null): string | null => {
|
||||
if (!approval) return null;
|
||||
const scopedAgentId = approval.agentId?.trim() ?? "";
|
||||
if (scopedAgentId) return scopedAgentId;
|
||||
const scopedSessionKey = approval.sessionKey?.trim() ?? "";
|
||||
if (!scopedSessionKey) return null;
|
||||
const matched = params
|
||||
.getAgents()
|
||||
.find((agent) => agent.sessionKey.trim() === scopedSessionKey);
|
||||
return matched?.agentId ?? null;
|
||||
};
|
||||
|
||||
const snapshot = params.getPendingState();
|
||||
const approval = resolvePendingApproval(id, snapshot);
|
||||
|
||||
const removeLocalApproval = (approvalId: string) => {
|
||||
params.setPendingExecApprovalsByAgentId((current) => {
|
||||
return removePendingApprovalEverywhere({
|
||||
approvalsByAgentId: current,
|
||||
unscopedApprovals: [],
|
||||
approvalId,
|
||||
}).approvalsByAgentId;
|
||||
});
|
||||
params.setUnscopedPendingExecApprovals((current) => {
|
||||
return removePendingApprovalEverywhere({
|
||||
approvalsByAgentId: {},
|
||||
unscopedApprovals: current,
|
||||
approvalId,
|
||||
}).unscopedApprovals;
|
||||
});
|
||||
};
|
||||
|
||||
const setLocalApprovalState = (resolving: boolean, error: string | null) => {
|
||||
params.setPendingExecApprovalsByAgentId((current) => {
|
||||
let changed = false;
|
||||
const next: Record<string, PendingExecApproval[]> = {};
|
||||
for (const [agentId, approvals] of Object.entries(current)) {
|
||||
const updated = updatePendingApprovalById(approvals, id, (approval) => ({
|
||||
...approval,
|
||||
resolving,
|
||||
error,
|
||||
}));
|
||||
if (updated !== approvals) {
|
||||
changed = true;
|
||||
}
|
||||
if (updated.length > 0) {
|
||||
next[agentId] = updated;
|
||||
}
|
||||
}
|
||||
return changed ? next : current;
|
||||
});
|
||||
params.setUnscopedPendingExecApprovals((current) =>
|
||||
updatePendingApprovalById(current, id, (approval) => ({
|
||||
...approval,
|
||||
resolving,
|
||||
error,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
setLocalApprovalState(true, null);
|
||||
|
||||
try {
|
||||
await params.client.call("exec.approval.resolve", { id, decision: params.decision });
|
||||
removeLocalApproval(id);
|
||||
|
||||
if (params.decision !== "allow-once" && params.decision !== "allow-always") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!approval) return;
|
||||
const targetAgentId = resolveApprovalTargetAgentId(approval);
|
||||
if (!targetAgentId) return;
|
||||
await params.onAllowResolved?.({ approval, targetAgentId });
|
||||
|
||||
const latest = params.getLatestAgent(targetAgentId);
|
||||
const activeRunId = latest?.runId?.trim() ?? "";
|
||||
if (activeRunId) {
|
||||
try {
|
||||
await params.client.call("agent.wait", { runId: activeRunId, timeoutMs: 15_000 });
|
||||
} catch (waitError) {
|
||||
if (!params.isDisconnectLikeError(waitError)) {
|
||||
(params.logWarn ?? ((message, error) => console.warn(message, error)))(
|
||||
"Failed to wait for run after exec approval resolve.",
|
||||
waitError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await params.requestHistoryRefresh(targetAgentId);
|
||||
await params.onAllowed?.({ approval, targetAgentId });
|
||||
} catch (err) {
|
||||
const shouldTreatUnknownId = params.shouldTreatUnknownId ?? shouldTreatExecApprovalResolveErrorAsUnknownId;
|
||||
if (shouldTreatUnknownId(err)) {
|
||||
removeLocalApproval(id);
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Failed to resolve exec approval.";
|
||||
setLocalApprovalState(false, message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
import type {
|
||||
ExecApprovalDecision,
|
||||
PendingExecApproval,
|
||||
} from "@/features/agents/approvals/types";
|
||||
import type {
|
||||
ExecApprovalIngressCommand,
|
||||
ExecApprovalPendingSnapshot,
|
||||
} from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
|
||||
import { resolveExecApprovalViaStudio } from "@/features/agents/approvals/execApprovalResolveOperation";
|
||||
import {
|
||||
planApprovalIngressRunControl,
|
||||
planAutoResumeRunControl,
|
||||
planPauseRunControl,
|
||||
} from "@/features/agents/approvals/execApprovalRunControlWorkflow";
|
||||
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type RunControlDispatchAction =
|
||||
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
| { type: "appendOutput"; agentId: string; line: string; transcript?: Record<string, unknown> }
|
||||
| { type: "markActivity"; agentId: string; at?: number };
|
||||
|
||||
type RunControlDispatch = (action: RunControlDispatchAction) => void;
|
||||
|
||||
type SetState<T> = (next: T | ((current: T) => T)) => void;
|
||||
|
||||
const AUTO_RESUME_FOLLOW_UP_MESSAGE = `${EXEC_APPROVAL_AUTO_RESUME_MARKER}\nContinue where you left off and finish the task.`;
|
||||
export const EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS = 3_000;
|
||||
|
||||
export async function runPauseRunForExecApprovalOperation(params: {
|
||||
status: string;
|
||||
client: GatewayClientLike;
|
||||
approval: PendingExecApproval;
|
||||
preferredAgentId?: string | null;
|
||||
getAgents: () => AgentState[];
|
||||
pausedRunIdByAgentId: Map<string, string>;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
logWarn?: (message: string, error: unknown) => void;
|
||||
}): Promise<void> {
|
||||
if (params.status !== "connected") return;
|
||||
|
||||
const plan = planPauseRunControl({
|
||||
approval: params.approval,
|
||||
preferredAgentId: params.preferredAgentId ?? null,
|
||||
agents: params.getAgents(),
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
});
|
||||
for (const agentId of plan.stalePausedAgentIds) {
|
||||
params.pausedRunIdByAgentId.delete(agentId);
|
||||
}
|
||||
if (plan.pauseIntent.kind !== "pause") {
|
||||
return;
|
||||
}
|
||||
|
||||
params.pausedRunIdByAgentId.set(plan.pauseIntent.agentId, plan.pauseIntent.runId);
|
||||
try {
|
||||
await params.client.call("chat.abort", {
|
||||
sessionKey: plan.pauseIntent.sessionKey,
|
||||
});
|
||||
} catch (error) {
|
||||
params.pausedRunIdByAgentId.delete(plan.pauseIntent.agentId);
|
||||
if (!params.isDisconnectLikeError(error)) {
|
||||
(params.logWarn ?? ((message, err) => console.warn(message, err)))(
|
||||
"Failed to pause run for pending exec approval.",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runExecApprovalAutoResumeOperation(params: {
|
||||
client: GatewayClientLike;
|
||||
dispatch: RunControlDispatch;
|
||||
approval: PendingExecApproval;
|
||||
targetAgentId: string;
|
||||
getAgents: () => AgentState[];
|
||||
getPendingState: () => ExecApprovalPendingSnapshot;
|
||||
pausedRunIdByAgentId: Map<string, string>;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
logWarn?: (message: string, error: unknown) => void;
|
||||
clearRunTracking?: (runId: string) => void;
|
||||
sendChatMessage?: typeof sendChatMessageViaStudio;
|
||||
now?: () => number;
|
||||
}): Promise<void> {
|
||||
const sendChatMessage = params.sendChatMessage ?? sendChatMessageViaStudio;
|
||||
const pendingState = params.getPendingState();
|
||||
const prePlan = planAutoResumeRunControl({
|
||||
approval: params.approval,
|
||||
targetAgentId: params.targetAgentId,
|
||||
pendingState,
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
agents: params.getAgents(),
|
||||
});
|
||||
if (prePlan.preWaitIntent.kind !== "resume") {
|
||||
return;
|
||||
}
|
||||
|
||||
const preWaitIntent = prePlan.preWaitIntent;
|
||||
params.pausedRunIdByAgentId.delete(preWaitIntent.targetAgentId);
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId: preWaitIntent.targetAgentId,
|
||||
patch: {
|
||||
status: "running",
|
||||
runId: preWaitIntent.pausedRunId,
|
||||
lastActivityAt: (params.now ?? (() => Date.now()))(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await params.client.call("agent.wait", {
|
||||
runId: preWaitIntent.pausedRunId,
|
||||
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!params.isDisconnectLikeError(error)) {
|
||||
(params.logWarn ?? ((message, err) => console.warn(message, err)))(
|
||||
"Failed waiting for paused run before auto-resume.",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const postPlan = planAutoResumeRunControl({
|
||||
approval: params.approval,
|
||||
targetAgentId: preWaitIntent.targetAgentId,
|
||||
pendingState,
|
||||
pausedRunIdByAgentId: new Map([[preWaitIntent.targetAgentId, preWaitIntent.pausedRunId]]),
|
||||
agents: params.getAgents(),
|
||||
});
|
||||
if (postPlan.postWaitIntent.kind !== "resume") {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendChatMessage({
|
||||
client: params.client,
|
||||
dispatch: params.dispatch,
|
||||
getAgent: (agentId) => params.getAgents().find((entry) => entry.agentId === agentId) ?? null,
|
||||
agentId: postPlan.postWaitIntent.targetAgentId,
|
||||
sessionKey: postPlan.postWaitIntent.sessionKey,
|
||||
message: AUTO_RESUME_FOLLOW_UP_MESSAGE,
|
||||
clearRunTracking: params.clearRunTracking,
|
||||
echoUserMessage: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runResolveExecApprovalOperation(params: {
|
||||
client: GatewayClientLike;
|
||||
approvalId: string;
|
||||
decision: ExecApprovalDecision;
|
||||
getAgents: () => AgentState[];
|
||||
getPendingState: () => ExecApprovalPendingSnapshot;
|
||||
setPendingExecApprovalsByAgentId: SetState<Record<string, PendingExecApproval[]>>;
|
||||
setUnscopedPendingExecApprovals: SetState<PendingExecApproval[]>;
|
||||
requestHistoryRefresh: (agentId: string) => Promise<void> | void;
|
||||
pausedRunIdByAgentId: Map<string, string>;
|
||||
dispatch: RunControlDispatch;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
logWarn?: (message: string, error: unknown) => void;
|
||||
clearRunTracking?: (runId: string) => void;
|
||||
resolveExecApproval?: typeof resolveExecApprovalViaStudio;
|
||||
runAutoResume?: typeof runExecApprovalAutoResumeOperation;
|
||||
}): Promise<void> {
|
||||
const resolveExecApproval = params.resolveExecApproval ?? resolveExecApprovalViaStudio;
|
||||
const runAutoResume = params.runAutoResume ?? runExecApprovalAutoResumeOperation;
|
||||
|
||||
await resolveExecApproval({
|
||||
client: params.client,
|
||||
approvalId: params.approvalId,
|
||||
decision: params.decision,
|
||||
getAgents: params.getAgents,
|
||||
getLatestAgent: (agentId) =>
|
||||
params.getAgents().find((entry) => entry.agentId === agentId) ?? null,
|
||||
getPendingState: params.getPendingState,
|
||||
setPendingExecApprovalsByAgentId: params.setPendingExecApprovalsByAgentId,
|
||||
setUnscopedPendingExecApprovals: params.setUnscopedPendingExecApprovals,
|
||||
requestHistoryRefresh: params.requestHistoryRefresh,
|
||||
onAllowed: async ({ approval, targetAgentId }) => {
|
||||
await runAutoResume({
|
||||
client: params.client,
|
||||
dispatch: params.dispatch,
|
||||
approval,
|
||||
targetAgentId,
|
||||
getAgents: params.getAgents,
|
||||
getPendingState: params.getPendingState,
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
isDisconnectLikeError: params.isDisconnectLikeError,
|
||||
logWarn: params.logWarn,
|
||||
clearRunTracking: params.clearRunTracking,
|
||||
});
|
||||
},
|
||||
isDisconnectLikeError: params.isDisconnectLikeError,
|
||||
logWarn: params.logWarn,
|
||||
});
|
||||
}
|
||||
|
||||
export function executeExecApprovalIngressCommands(params: {
|
||||
commands: ExecApprovalIngressCommand[];
|
||||
replacePendingState: (nextPendingState: ExecApprovalPendingSnapshot) => void;
|
||||
pauseRunForApproval: (
|
||||
approval: PendingExecApproval,
|
||||
preferredAgentId: string | null
|
||||
) => Promise<void> | void;
|
||||
dispatch: RunControlDispatch;
|
||||
recordCronDedupeKey: (dedupeKey: string) => void;
|
||||
}): void {
|
||||
for (const command of params.commands) {
|
||||
if (command.kind === "replacePendingState") {
|
||||
params.replacePendingState(command.pendingState);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "pauseRunForApproval") {
|
||||
void params.pauseRunForApproval(command.approval, command.preferredAgentId);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "markActivity") {
|
||||
params.dispatch({
|
||||
type: "markActivity",
|
||||
agentId: command.agentId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "recordCronDedupeKey") {
|
||||
params.recordCronDedupeKey(command.dedupeKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
const intent = command.intent;
|
||||
params.dispatch({
|
||||
type: "appendOutput",
|
||||
agentId: intent.agentId,
|
||||
line: intent.line,
|
||||
transcript: {
|
||||
source: "runtime-agent",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
sessionKey: intent.sessionKey,
|
||||
timestampMs: intent.timestampMs,
|
||||
entryId: intent.dedupeKey,
|
||||
confirmed: true,
|
||||
},
|
||||
});
|
||||
params.dispatch({
|
||||
type: "markActivity",
|
||||
agentId: intent.agentId,
|
||||
at: intent.activityAtMs ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function runGatewayEventIngressOperation(params: {
|
||||
event: EventFrame;
|
||||
getAgents: () => AgentState[];
|
||||
getPendingState: () => ExecApprovalPendingSnapshot;
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
seenCronDedupeKeys: ReadonlySet<string>;
|
||||
nowMs: number;
|
||||
replacePendingState: (nextPendingState: ExecApprovalPendingSnapshot) => void;
|
||||
pauseRunForApproval: (
|
||||
approval: PendingExecApproval,
|
||||
preferredAgentId: string | null
|
||||
) => Promise<void> | void;
|
||||
dispatch: RunControlDispatch;
|
||||
recordCronDedupeKey: (dedupeKey: string) => void;
|
||||
}): ExecApprovalIngressCommand[] {
|
||||
const commands = planApprovalIngressRunControl({
|
||||
event: params.event,
|
||||
agents: params.getAgents(),
|
||||
pendingState: params.getPendingState(),
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
seenCronDedupeKeys: params.seenCronDedupeKeys,
|
||||
nowMs: params.nowMs,
|
||||
});
|
||||
executeExecApprovalIngressCommands({
|
||||
commands,
|
||||
replacePendingState: params.replacePendingState,
|
||||
pauseRunForApproval: params.pauseRunForApproval,
|
||||
dispatch: params.dispatch,
|
||||
recordCronDedupeKey: params.recordCronDedupeKey,
|
||||
});
|
||||
return commands;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
planAutoResumeIntent,
|
||||
planIngressCommands,
|
||||
planPausedRunMapCleanup,
|
||||
planPauseRunIntent,
|
||||
type ExecApprovalIngressCommand,
|
||||
type ExecApprovalPendingSnapshot,
|
||||
} from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
type GatewayEventFrame = Parameters<typeof planIngressCommands>[0]["event"];
|
||||
|
||||
export type PauseRunControlPlan = {
|
||||
stalePausedAgentIds: string[];
|
||||
pauseIntent: ReturnType<typeof planPauseRunIntent>;
|
||||
};
|
||||
|
||||
export type AutoResumeRunControlPlan = {
|
||||
preWaitIntent: ReturnType<typeof planAutoResumeIntent>;
|
||||
postWaitIntent: ReturnType<typeof planAutoResumeIntent>;
|
||||
};
|
||||
|
||||
export function planPauseRunControl(params: {
|
||||
approval: PendingExecApproval;
|
||||
preferredAgentId: string | null;
|
||||
agents: AgentState[];
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
}): PauseRunControlPlan {
|
||||
return {
|
||||
stalePausedAgentIds: planPausedRunMapCleanup({
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
agents: params.agents,
|
||||
}),
|
||||
pauseIntent: planPauseRunIntent({
|
||||
approval: params.approval,
|
||||
preferredAgentId: params.preferredAgentId,
|
||||
agents: params.agents,
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function planAutoResumeRunControl(params: {
|
||||
approval: PendingExecApproval;
|
||||
targetAgentId: string;
|
||||
pendingState: ExecApprovalPendingSnapshot;
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
agents: AgentState[];
|
||||
}): AutoResumeRunControlPlan {
|
||||
const preWaitIntent = planAutoResumeIntent({
|
||||
approval: params.approval,
|
||||
targetAgentId: params.targetAgentId,
|
||||
pendingState: params.pendingState,
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
agents: params.agents,
|
||||
});
|
||||
if (preWaitIntent.kind !== "resume") {
|
||||
return {
|
||||
preWaitIntent,
|
||||
postWaitIntent: preWaitIntent,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
preWaitIntent,
|
||||
postWaitIntent: planAutoResumeIntent({
|
||||
approval: params.approval,
|
||||
targetAgentId: preWaitIntent.targetAgentId,
|
||||
pendingState: params.pendingState,
|
||||
pausedRunIdByAgentId: new Map([
|
||||
[preWaitIntent.targetAgentId, preWaitIntent.pausedRunId],
|
||||
]),
|
||||
agents: params.agents,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function planApprovalIngressRunControl(params: {
|
||||
event: GatewayEventFrame;
|
||||
agents: AgentState[];
|
||||
pendingState: ExecApprovalPendingSnapshot;
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
seenCronDedupeKeys: ReadonlySet<string>;
|
||||
nowMs: number;
|
||||
}): ExecApprovalIngressCommand[] {
|
||||
return planIngressCommands({
|
||||
event: params.event,
|
||||
agents: params.agents,
|
||||
pendingState: params.pendingState,
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
seenCronDedupeKeys: params.seenCronDedupeKeys,
|
||||
nowMs: params.nowMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import { shouldPauseRunForPendingExecApproval } from "@/features/agents/approvals/execApprovalPausePolicy";
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
nextPendingApprovalPruneDelayMs,
|
||||
pruneExpiredPendingApprovals,
|
||||
pruneExpiredPendingApprovalsMap,
|
||||
removePendingApprovalById,
|
||||
removePendingApprovalByIdMap,
|
||||
removePendingApprovalEverywhere,
|
||||
upsertPendingApproval,
|
||||
} from "@/features/agents/approvals/pendingStore";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
export type ApprovalPendingState = {
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||
unscopedApprovals: PendingExecApproval[];
|
||||
};
|
||||
|
||||
export type ApprovalPauseRequest = {
|
||||
approval: PendingExecApproval;
|
||||
preferredAgentId: string | null;
|
||||
};
|
||||
|
||||
export type ApprovalIngressResult = {
|
||||
pendingState: ApprovalPendingState;
|
||||
pauseRequests: ApprovalPauseRequest[];
|
||||
markActivityAgentIds: string[];
|
||||
};
|
||||
|
||||
export type AwaitingUserInputPatch = {
|
||||
agentId: string;
|
||||
awaitingUserInput: boolean;
|
||||
};
|
||||
|
||||
export type AutoResumePreflightIntent =
|
||||
| { kind: "skip"; reason: "missing-paused-run" | "blocking-pending-approvals" }
|
||||
| { kind: "resume"; targetAgentId: string; pausedRunId: string };
|
||||
|
||||
export type AutoResumeDispatchIntent =
|
||||
| { kind: "skip"; reason: "missing-paused-run" | "missing-agent" | "run-replaced" | "missing-session-key" }
|
||||
| { kind: "resume"; targetAgentId: string; pausedRunId: string; sessionKey: string };
|
||||
|
||||
const resolveAgentForPauseRequest = (params: {
|
||||
approval: PendingExecApproval;
|
||||
preferredAgentId: string | null;
|
||||
agents: AgentState[];
|
||||
}): AgentState | null => {
|
||||
const preferredAgentId = params.preferredAgentId?.trim() ?? "";
|
||||
if (preferredAgentId) {
|
||||
const match = params.agents.find((agent) => agent.agentId === preferredAgentId) ?? null;
|
||||
if (match) return match;
|
||||
}
|
||||
const approvalSessionKey = params.approval.sessionKey?.trim() ?? "";
|
||||
if (!approvalSessionKey) return null;
|
||||
return (
|
||||
params.agents.find((agent) => agent.sessionKey.trim() === approvalSessionKey) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const shouldQueuePauseRequest = (params: {
|
||||
approval: PendingExecApproval;
|
||||
preferredAgentId: string | null;
|
||||
agents: AgentState[];
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
}): boolean => {
|
||||
const agent = resolveAgentForPauseRequest(params);
|
||||
if (!agent) return false;
|
||||
const pausedRunId = params.pausedRunIdByAgentId.get(agent.agentId) ?? null;
|
||||
return shouldPauseRunForPendingExecApproval({
|
||||
agent,
|
||||
approval: params.approval,
|
||||
pausedRunId,
|
||||
});
|
||||
};
|
||||
|
||||
export const applyApprovalIngressEffects = (params: {
|
||||
pendingState: ApprovalPendingState;
|
||||
approvalEffects: ExecApprovalEventEffects | null;
|
||||
agents: AgentState[];
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
}): ApprovalIngressResult => {
|
||||
const effects = params.approvalEffects;
|
||||
if (!effects) {
|
||||
return {
|
||||
pendingState: params.pendingState,
|
||||
pauseRequests: [],
|
||||
markActivityAgentIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
let approvalsByAgentId = params.pendingState.approvalsByAgentId;
|
||||
let unscopedApprovals = params.pendingState.unscopedApprovals;
|
||||
const pauseRequests: ApprovalPauseRequest[] = [];
|
||||
|
||||
for (const approvalId of effects.removals) {
|
||||
const removed = removePendingApprovalEverywhere({
|
||||
approvalsByAgentId,
|
||||
unscopedApprovals,
|
||||
approvalId,
|
||||
});
|
||||
approvalsByAgentId = removed.approvalsByAgentId;
|
||||
unscopedApprovals = removed.unscopedApprovals;
|
||||
}
|
||||
|
||||
for (const scopedUpsert of effects.scopedUpserts) {
|
||||
approvalsByAgentId = removePendingApprovalByIdMap(
|
||||
approvalsByAgentId,
|
||||
scopedUpsert.approval.id
|
||||
);
|
||||
const existing = approvalsByAgentId[scopedUpsert.agentId] ?? [];
|
||||
const upserted = upsertPendingApproval(existing, scopedUpsert.approval);
|
||||
if (upserted !== existing) {
|
||||
approvalsByAgentId = {
|
||||
...approvalsByAgentId,
|
||||
[scopedUpsert.agentId]: upserted,
|
||||
};
|
||||
}
|
||||
unscopedApprovals = removePendingApprovalById(
|
||||
unscopedApprovals,
|
||||
scopedUpsert.approval.id
|
||||
);
|
||||
if (
|
||||
shouldQueuePauseRequest({
|
||||
approval: scopedUpsert.approval,
|
||||
preferredAgentId: scopedUpsert.agentId,
|
||||
agents: params.agents,
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
})
|
||||
) {
|
||||
pauseRequests.push({
|
||||
approval: scopedUpsert.approval,
|
||||
preferredAgentId: scopedUpsert.agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const unscopedUpsert of effects.unscopedUpserts) {
|
||||
approvalsByAgentId = removePendingApprovalByIdMap(
|
||||
approvalsByAgentId,
|
||||
unscopedUpsert.id
|
||||
);
|
||||
const withoutExisting = removePendingApprovalById(
|
||||
unscopedApprovals,
|
||||
unscopedUpsert.id
|
||||
);
|
||||
unscopedApprovals = upsertPendingApproval(withoutExisting, unscopedUpsert);
|
||||
if (
|
||||
shouldQueuePauseRequest({
|
||||
approval: unscopedUpsert,
|
||||
preferredAgentId: null,
|
||||
agents: params.agents,
|
||||
pausedRunIdByAgentId: params.pausedRunIdByAgentId,
|
||||
})
|
||||
) {
|
||||
pauseRequests.push({
|
||||
approval: unscopedUpsert,
|
||||
preferredAgentId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pendingState: {
|
||||
approvalsByAgentId,
|
||||
unscopedApprovals,
|
||||
},
|
||||
pauseRequests,
|
||||
markActivityAgentIds: effects.markActivityAgentIds,
|
||||
};
|
||||
};
|
||||
|
||||
export const deriveAwaitingUserInputPatches = (params: {
|
||||
agents: AgentState[];
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||
}): AwaitingUserInputPatch[] => {
|
||||
const pendingCountsByAgentId = new Map<string, number>();
|
||||
for (const [agentId, approvals] of Object.entries(params.approvalsByAgentId)) {
|
||||
if (approvals.length <= 0) continue;
|
||||
pendingCountsByAgentId.set(agentId, approvals.length);
|
||||
}
|
||||
|
||||
const patches: AwaitingUserInputPatch[] = [];
|
||||
for (const agent of params.agents) {
|
||||
const awaitingUserInput = (pendingCountsByAgentId.get(agent.agentId) ?? 0) > 0;
|
||||
if (agent.awaitingUserInput === awaitingUserInput) continue;
|
||||
patches.push({
|
||||
agentId: agent.agentId,
|
||||
awaitingUserInput,
|
||||
});
|
||||
}
|
||||
return patches;
|
||||
};
|
||||
|
||||
export const derivePendingApprovalPruneDelayMs = (params: {
|
||||
pendingState: ApprovalPendingState;
|
||||
nowMs: number;
|
||||
graceMs: number;
|
||||
}): number | null => {
|
||||
return nextPendingApprovalPruneDelayMs({
|
||||
approvalsByAgentId: params.pendingState.approvalsByAgentId,
|
||||
unscopedApprovals: params.pendingState.unscopedApprovals,
|
||||
nowMs: params.nowMs,
|
||||
graceMs: params.graceMs,
|
||||
});
|
||||
};
|
||||
|
||||
export const prunePendingApprovalState = (params: {
|
||||
pendingState: ApprovalPendingState;
|
||||
nowMs: number;
|
||||
graceMs: number;
|
||||
}): { pendingState: ApprovalPendingState } => {
|
||||
return {
|
||||
pendingState: {
|
||||
approvalsByAgentId: pruneExpiredPendingApprovalsMap(
|
||||
params.pendingState.approvalsByAgentId,
|
||||
{
|
||||
nowMs: params.nowMs,
|
||||
graceMs: params.graceMs,
|
||||
}
|
||||
),
|
||||
unscopedApprovals: pruneExpiredPendingApprovals(
|
||||
params.pendingState.unscopedApprovals,
|
||||
{
|
||||
nowMs: params.nowMs,
|
||||
graceMs: params.graceMs,
|
||||
}
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveApprovalAutoResumePreflight = (params: {
|
||||
approval: PendingExecApproval;
|
||||
targetAgentId: string;
|
||||
pendingState: ApprovalPendingState;
|
||||
pausedRunIdByAgentId: ReadonlyMap<string, string>;
|
||||
}): AutoResumePreflightIntent => {
|
||||
const pausedRunId = params.pausedRunIdByAgentId.get(params.targetAgentId)?.trim() ?? "";
|
||||
if (!pausedRunId) {
|
||||
return { kind: "skip", reason: "missing-paused-run" };
|
||||
}
|
||||
|
||||
const scopedPending = (
|
||||
params.pendingState.approvalsByAgentId[params.targetAgentId] ?? []
|
||||
).some((pendingApproval) => pendingApproval.id !== params.approval.id);
|
||||
|
||||
const targetSessionKey = params.approval.sessionKey?.trim() ?? "";
|
||||
const unscopedPending = params.pendingState.unscopedApprovals.some((pendingApproval) => {
|
||||
if (pendingApproval.id === params.approval.id) return false;
|
||||
const pendingAgentId = pendingApproval.agentId?.trim() ?? "";
|
||||
if (pendingAgentId && pendingAgentId === params.targetAgentId) return true;
|
||||
if (!targetSessionKey) return false;
|
||||
return (pendingApproval.sessionKey?.trim() ?? "") === targetSessionKey;
|
||||
});
|
||||
|
||||
if (scopedPending || unscopedPending) {
|
||||
return { kind: "skip", reason: "blocking-pending-approvals" };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "resume",
|
||||
targetAgentId: params.targetAgentId,
|
||||
pausedRunId,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveApprovalAutoResumeDispatch = (params: {
|
||||
targetAgentId: string;
|
||||
pausedRunId: string;
|
||||
agents: AgentState[];
|
||||
}): AutoResumeDispatchIntent => {
|
||||
const pausedRunId = params.pausedRunId.trim();
|
||||
if (!pausedRunId) {
|
||||
return { kind: "skip", reason: "missing-paused-run" };
|
||||
}
|
||||
|
||||
const latest =
|
||||
params.agents.find((agent) => agent.agentId === params.targetAgentId) ?? null;
|
||||
if (!latest) {
|
||||
return { kind: "skip", reason: "missing-agent" };
|
||||
}
|
||||
|
||||
const latestRunId = latest.runId?.trim() ?? "";
|
||||
if (latest.status === "running" && latestRunId && latestRunId !== pausedRunId) {
|
||||
return { kind: "skip", reason: "run-replaced" };
|
||||
}
|
||||
|
||||
const sessionKey = latest.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return { kind: "skip", reason: "missing-session-key" };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "resume",
|
||||
targetAgentId: params.targetAgentId,
|
||||
pausedRunId,
|
||||
sessionKey,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
|
||||
export const upsertPendingApproval = (
|
||||
approvals: PendingExecApproval[],
|
||||
nextApproval: PendingExecApproval
|
||||
): PendingExecApproval[] => {
|
||||
const index = approvals.findIndex((entry) => entry.id === nextApproval.id);
|
||||
if (index < 0) {
|
||||
return [nextApproval, ...approvals];
|
||||
}
|
||||
const next = [...approvals];
|
||||
next[index] = nextApproval;
|
||||
return next;
|
||||
};
|
||||
|
||||
export const mergePendingApprovalsForFocusedAgent = (params: {
|
||||
scopedApprovals: PendingExecApproval[];
|
||||
unscopedApprovals: PendingExecApproval[];
|
||||
}): PendingExecApproval[] => {
|
||||
if (params.scopedApprovals.length === 0) return params.unscopedApprovals;
|
||||
if (params.unscopedApprovals.length === 0) return params.scopedApprovals;
|
||||
const merged = [...params.unscopedApprovals];
|
||||
const seen = new Map<string, number>();
|
||||
for (let index = 0; index < merged.length; index += 1) {
|
||||
seen.set(merged[index]!.id, index);
|
||||
}
|
||||
for (const approval of params.scopedApprovals) {
|
||||
const existingIndex = seen.get(approval.id);
|
||||
if (existingIndex === undefined) {
|
||||
seen.set(approval.id, merged.length);
|
||||
merged.push(approval);
|
||||
continue;
|
||||
}
|
||||
merged[existingIndex] = approval;
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
export const updatePendingApprovalById = (
|
||||
approvals: PendingExecApproval[],
|
||||
approvalId: string,
|
||||
updater: (approval: PendingExecApproval) => PendingExecApproval
|
||||
): PendingExecApproval[] => {
|
||||
let changed = false;
|
||||
const next = approvals.map((approval) => {
|
||||
if (approval.id !== approvalId) return approval;
|
||||
changed = true;
|
||||
return updater(approval);
|
||||
});
|
||||
return changed ? next : approvals;
|
||||
};
|
||||
|
||||
export const removePendingApprovalById = (
|
||||
approvals: PendingExecApproval[],
|
||||
approvalId: string
|
||||
): PendingExecApproval[] => approvals.filter((approval) => approval.id !== approvalId);
|
||||
|
||||
export const removePendingApprovalEverywhere = (params: {
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||
unscopedApprovals: PendingExecApproval[];
|
||||
approvalId: string;
|
||||
}): {
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||
unscopedApprovals: PendingExecApproval[];
|
||||
} => {
|
||||
const hasScoped = Object.values(params.approvalsByAgentId).some((approvals) =>
|
||||
approvals.some((approval) => approval.id === params.approvalId)
|
||||
);
|
||||
const hasUnscoped = params.unscopedApprovals.some(
|
||||
(approval) => approval.id === params.approvalId
|
||||
);
|
||||
if (!hasScoped && !hasUnscoped) {
|
||||
return {
|
||||
approvalsByAgentId: params.approvalsByAgentId,
|
||||
unscopedApprovals: params.unscopedApprovals,
|
||||
};
|
||||
}
|
||||
return {
|
||||
approvalsByAgentId: hasScoped
|
||||
? removePendingApprovalByIdMap(params.approvalsByAgentId, params.approvalId)
|
||||
: params.approvalsByAgentId,
|
||||
unscopedApprovals: hasUnscoped
|
||||
? removePendingApprovalById(params.unscopedApprovals, params.approvalId)
|
||||
: params.unscopedApprovals,
|
||||
};
|
||||
};
|
||||
|
||||
export const removePendingApprovalByIdMap = (
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>,
|
||||
approvalId: string
|
||||
): Record<string, PendingExecApproval[]> => {
|
||||
let changed = false;
|
||||
const next: Record<string, PendingExecApproval[]> = {};
|
||||
for (const [agentId, approvals] of Object.entries(approvalsByAgentId)) {
|
||||
const filtered = removePendingApprovalById(approvals, approvalId);
|
||||
if (filtered.length !== approvals.length) {
|
||||
changed = true;
|
||||
}
|
||||
if (filtered.length > 0) {
|
||||
next[agentId] = filtered;
|
||||
}
|
||||
}
|
||||
return changed ? next : approvalsByAgentId;
|
||||
};
|
||||
|
||||
export const pruneExpiredPendingApprovals = (
|
||||
approvals: PendingExecApproval[],
|
||||
params: { nowMs: number; graceMs: number }
|
||||
): PendingExecApproval[] => {
|
||||
const cutoff = params.nowMs - params.graceMs;
|
||||
return approvals.filter((approval) => approval.expiresAtMs >= cutoff);
|
||||
};
|
||||
|
||||
export const pruneExpiredPendingApprovalsMap = (
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>,
|
||||
params: { nowMs: number; graceMs: number }
|
||||
): Record<string, PendingExecApproval[]> => {
|
||||
let changed = false;
|
||||
const next: Record<string, PendingExecApproval[]> = {};
|
||||
for (const [agentId, approvals] of Object.entries(approvalsByAgentId)) {
|
||||
const filtered = pruneExpiredPendingApprovals(approvals, params);
|
||||
if (filtered.length !== approvals.length) {
|
||||
changed = true;
|
||||
}
|
||||
if (filtered.length > 0) {
|
||||
next[agentId] = filtered;
|
||||
}
|
||||
}
|
||||
return changed ? next : approvalsByAgentId;
|
||||
};
|
||||
|
||||
export const nextPendingApprovalPruneDelayMs = (params: {
|
||||
approvalsByAgentId: Record<string, PendingExecApproval[]>;
|
||||
unscopedApprovals: PendingExecApproval[];
|
||||
nowMs: number;
|
||||
graceMs: number;
|
||||
}): number | null => {
|
||||
let earliestExpiresMs = Number.POSITIVE_INFINITY;
|
||||
for (const approvals of Object.values(params.approvalsByAgentId)) {
|
||||
for (const approval of approvals) {
|
||||
if (approval.expiresAtMs < earliestExpiresMs) {
|
||||
earliestExpiresMs = approval.expiresAtMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const approval of params.unscopedApprovals) {
|
||||
if (approval.expiresAtMs < earliestExpiresMs) {
|
||||
earliestExpiresMs = approval.expiresAtMs;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(earliestExpiresMs)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, earliestExpiresMs + params.graceMs - params.nowMs);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
export type PendingExecApproval = {
|
||||
id: string;
|
||||
agentId: string | null;
|
||||
sessionKey: string | null;
|
||||
command: string;
|
||||
cwd: string | null;
|
||||
host: string | null;
|
||||
security: string | null;
|
||||
ask: string | null;
|
||||
resolvedPath: string | null;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
resolving: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import Image from "next/image";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
|
||||
|
||||
type AgentAvatarProps = {
|
||||
seed: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
size?: number;
|
||||
isSelected?: boolean;
|
||||
};
|
||||
|
||||
export const AgentAvatar = ({
|
||||
seed,
|
||||
name,
|
||||
avatarUrl,
|
||||
size = 112,
|
||||
isSelected = false,
|
||||
}: AgentAvatarProps) => {
|
||||
const src = useMemo(() => {
|
||||
const trimmed = avatarUrl?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
return buildAvatarDataUrl(seed);
|
||||
}, [avatarUrl, seed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center overflow-hidden rounded-full border border-border/80 bg-card transition-transform duration-300 ${isSelected ? "agent-avatar-selected scale-[1.02]" : ""}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Image
|
||||
className="pointer-events-none h-full w-full select-none"
|
||||
src={src}
|
||||
alt={`Avatar for ${name}`}
|
||||
width={size}
|
||||
height={size}
|
||||
unoptimized
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Shuffle } from "lucide-react";
|
||||
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
||||
import { AgentAvatar } from "@/features/agents/components/AgentAvatar";
|
||||
import { randomUUID } from "@/lib/uuid";
|
||||
|
||||
type AgentCreateModalProps = {
|
||||
open: boolean;
|
||||
suggestedName: string;
|
||||
busy?: boolean;
|
||||
submitError?: string | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: AgentCreateModalSubmitPayload) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const fieldClassName =
|
||||
"ui-input w-full rounded-md px-3 py-2 text-xs text-foreground outline-none";
|
||||
const labelClassName =
|
||||
"font-mono text-[11px] font-semibold tracking-[0.05em] text-muted-foreground";
|
||||
|
||||
const resolveInitialName = (suggestedName: string): string => {
|
||||
const trimmed = suggestedName.trim();
|
||||
if (!trimmed) return "New Agent";
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const AgentCreateModalContent = ({
|
||||
suggestedName,
|
||||
busy,
|
||||
submitError,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Omit<AgentCreateModalProps, "open">) => {
|
||||
const [name, setName] = useState(() => resolveInitialName(suggestedName));
|
||||
const [avatarSeed, setAvatarSeed] = useState(() => randomUUID());
|
||||
|
||||
const canSubmit = name.trim().length > 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit || busy) return;
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) return;
|
||||
void onSubmit({ name: trimmedName, avatarSeed });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-background/80 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Create agent"
|
||||
onClick={busy ? undefined : onClose}
|
||||
>
|
||||
<form
|
||||
className="ui-panel w-full max-w-2xl shadow-xs"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
data-testid="agent-create-modal"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border/35 px-6 py-6">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||
New agent
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-foreground">Launch agent</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">Name it and activate immediately.</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-ghost px-3 py-1.5 font-mono text-[11px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 px-6 py-5">
|
||||
<label className={labelClassName}>
|
||||
Name
|
||||
<input
|
||||
aria-label="Agent name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={`mt-1 ${fieldClassName}`}
|
||||
placeholder="My agent"
|
||||
/>
|
||||
</label>
|
||||
<div className="-mt-2 text-[11px] text-muted-foreground">
|
||||
You can rename this agent from the main chat header.
|
||||
</div>
|
||||
<div className="grid justify-items-center gap-2 border-t border-border/40 pt-3">
|
||||
<div className={labelClassName}>Choose avatar</div>
|
||||
<AgentAvatar
|
||||
seed={avatarSeed}
|
||||
name={name.trim() || "New Agent"}
|
||||
size={64}
|
||||
isSelected
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Shuffle avatar selection"
|
||||
className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"
|
||||
onClick={() => setAvatarSeed(randomUUID())}
|
||||
disabled={busy}
|
||||
>
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
Shuffle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{submitError ? (
|
||||
<div className="ui-alert-danger rounded-md px-3 py-2 text-xs">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border/45 px-6 pb-4 pt-5">
|
||||
<div className="text-[11px] text-muted-foreground">Authority can be configured after launch.</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="ui-btn-primary px-3 py-1.5 font-mono text-[11px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
|
||||
disabled={!canSubmit || busy}
|
||||
>
|
||||
{busy ? "Launching..." : "Launch agent"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentCreateModal = ({
|
||||
open,
|
||||
suggestedName,
|
||||
busy = false,
|
||||
submitError = null,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AgentCreateModalProps) => {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<AgentCreateModalContent
|
||||
suggestedName={suggestedName}
|
||||
busy={busy}
|
||||
submitError={submitError}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
export { AgentBrainPanel, type AgentBrainPanelProps } from "@/features/agents/components/inspect/AgentBrainPanel";
|
||||
export {
|
||||
AgentSettingsPanel,
|
||||
type AgentSettingsPanelProps,
|
||||
} from "@/features/agents/components/inspect/AgentSettingsPanel";
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { SkillStatusReport } from "@/lib/skills/types";
|
||||
import {
|
||||
buildAgentSkillsAllowlistSet,
|
||||
buildSkillMissingDetails,
|
||||
deriveAgentSkillDisplayState,
|
||||
deriveAgentSkillsAccessMode,
|
||||
deriveSkillReadinessState,
|
||||
type AgentSkillDisplayState,
|
||||
} from "@/lib/skills/presentation";
|
||||
|
||||
type SkillRowFilter = "all" | AgentSkillDisplayState;
|
||||
|
||||
type AgentSkillsPanelProps = {
|
||||
skillsReport?: SkillStatusReport | null;
|
||||
skillsLoading?: boolean;
|
||||
skillsError?: string | null;
|
||||
skillsBusy?: boolean;
|
||||
skillsBusyKey?: string | null;
|
||||
skillsAllowlist?: string[] | undefined;
|
||||
onSetSkillEnabled: (skillName: string, enabled: boolean) => Promise<void> | void;
|
||||
onOpenSystemSetup: (skillKey?: string) => void;
|
||||
};
|
||||
|
||||
const FILTERS: Array<{ id: SkillRowFilter; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "ready", label: "Ready" },
|
||||
{ id: "setup-required", label: "Setup required" },
|
||||
{ id: "not-supported", label: "Not supported" },
|
||||
];
|
||||
|
||||
const DISPLAY_LABELS: Record<AgentSkillDisplayState, string> = {
|
||||
ready: "Ready",
|
||||
"setup-required": "Setup required",
|
||||
"not-supported": "Not supported",
|
||||
};
|
||||
|
||||
const DISPLAY_CLASSES: Record<AgentSkillDisplayState, string> = {
|
||||
ready: "ui-badge-status-running",
|
||||
"setup-required": "ui-badge-status-error",
|
||||
"not-supported": "ui-badge-status-error",
|
||||
};
|
||||
|
||||
const resolveHint = (
|
||||
skill: SkillStatusReport["skills"][number],
|
||||
displayState: AgentSkillDisplayState
|
||||
): string | null => {
|
||||
if (displayState === "ready") {
|
||||
return null;
|
||||
}
|
||||
if (displayState === "not-supported") {
|
||||
if (skill.blockedByAllowlist) {
|
||||
return "Blocked by bundled skills policy.";
|
||||
}
|
||||
return buildSkillMissingDetails(skill).find((line) => line.startsWith("Requires OS:")) ?? "Not supported.";
|
||||
}
|
||||
const readiness = deriveSkillReadinessState(skill);
|
||||
if (readiness === "disabled-globally") {
|
||||
return "Disabled globally. Enable it in System setup.";
|
||||
}
|
||||
return buildSkillMissingDetails(skill)[0] ?? "Requires setup in System setup.";
|
||||
};
|
||||
|
||||
export const AgentSkillsPanel = ({
|
||||
skillsReport = null,
|
||||
skillsLoading = false,
|
||||
skillsError = null,
|
||||
skillsBusy = false,
|
||||
skillsBusyKey = null,
|
||||
skillsAllowlist,
|
||||
onSetSkillEnabled,
|
||||
onOpenSystemSetup,
|
||||
}: AgentSkillsPanelProps) => {
|
||||
const [skillsFilter, setSkillsFilter] = useState("");
|
||||
const [rowFilter, setRowFilter] = useState<SkillRowFilter>("all");
|
||||
|
||||
const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]);
|
||||
const accessMode = deriveAgentSkillsAccessMode(skillsAllowlist);
|
||||
const allowlistSet = useMemo(() => buildAgentSkillsAllowlistSet(skillsAllowlist), [skillsAllowlist]);
|
||||
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return skillEntries.map((skill) => {
|
||||
const normalizedName = skill.name.trim();
|
||||
const allowed =
|
||||
accessMode === "all" ? true : accessMode === "none" ? false : allowlistSet.has(normalizedName);
|
||||
const readiness = deriveSkillReadinessState(skill);
|
||||
return {
|
||||
skill,
|
||||
allowed,
|
||||
displayState: deriveAgentSkillDisplayState(readiness),
|
||||
};
|
||||
});
|
||||
}, [accessMode, allowlistSet, skillEntries]);
|
||||
|
||||
const searchedRows = useMemo(() => {
|
||||
const query = skillsFilter.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((entry) =>
|
||||
[entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}, [rows, skillsFilter]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (rowFilter === "all") {
|
||||
return searchedRows;
|
||||
}
|
||||
return searchedRows.filter((entry) => entry.displayState === rowFilter);
|
||||
}, [rowFilter, searchedRows]);
|
||||
|
||||
const filterCounts = useMemo(
|
||||
() =>
|
||||
searchedRows.reduce(
|
||||
(counts, entry) => {
|
||||
counts.all += 1;
|
||||
counts[entry.displayState] += 1;
|
||||
return counts;
|
||||
},
|
||||
{
|
||||
all: 0,
|
||||
ready: 0,
|
||||
"setup-required": 0,
|
||||
"not-supported": 0,
|
||||
} satisfies Record<SkillRowFilter, number>
|
||||
),
|
||||
[searchedRows]
|
||||
);
|
||||
|
||||
const enabledCount = useMemo(
|
||||
() => rows.reduce((count, entry) => count + (entry.allowed ? 1 : 0), 0),
|
||||
[rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="sidebar-section" data-testid="agent-settings-skills">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="sidebar-section-title">Skills</h3>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{enabledCount}/{skillEntries.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">Skill access controls apply to this agent.</div>
|
||||
{accessMode === "selected" ? (
|
||||
<div className="mt-2 text-[10px] text-muted-foreground/80">
|
||||
This agent is using selected skills only.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
value={skillsFilter}
|
||||
onChange={(event) => setSkillsFilter(event.target.value)}
|
||||
placeholder="Search skills"
|
||||
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
|
||||
aria-label="Search skills"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{FILTERS.map((filter) => {
|
||||
const selected = rowFilter === filter.id;
|
||||
return (
|
||||
<button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
|
||||
data-active={selected ? "true" : "false"}
|
||||
disabled={skillsLoading}
|
||||
onClick={() => {
|
||||
setRowFilter(filter.id);
|
||||
}}
|
||||
>
|
||||
{filter.label} ({filterCounts[filter.id]})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
|
||||
{!skillsLoading && skillsError ? (
|
||||
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{filteredRows.map((entry) => {
|
||||
const statusLabel = DISPLAY_LABELS[entry.displayState];
|
||||
const statusClassName = DISPLAY_CLASSES[entry.displayState];
|
||||
const canConfigureInSystem = entry.displayState === "setup-required";
|
||||
const switchDisabled = anySkillBusy || entry.displayState === "not-supported";
|
||||
return (
|
||||
<div
|
||||
key={`${entry.skill.source}:${entry.skill.skillKey}`}
|
||||
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
|
||||
{entry.skill.source}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${statusClassName}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
|
||||
{entry.displayState !== "ready" ? (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/80">
|
||||
{resolveHint(entry.skill, entry.displayState)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2 sm:w-[240px] sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label={`Skill ${entry.skill.name}`}
|
||||
aria-checked={entry.allowed}
|
||||
className={`ui-switch self-start ${entry.allowed ? "ui-switch--on" : ""}`}
|
||||
disabled={switchDisabled}
|
||||
onClick={() => {
|
||||
void onSetSkillEnabled(entry.skill.name, !entry.allowed);
|
||||
}}
|
||||
>
|
||||
<span className="ui-switch-thumb" />
|
||||
</button>
|
||||
{canConfigureInSystem ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold"
|
||||
onClick={() => {
|
||||
onOpenSystemSetup(entry.skill.skillKey);
|
||||
}}
|
||||
>
|
||||
Open System Setup
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { SkillStatusEntry } from "@/lib/skills/types";
|
||||
import {
|
||||
buildSkillMissingDetails,
|
||||
canRemoveSkill,
|
||||
deriveSkillReadinessState,
|
||||
resolvePreferredInstallOption,
|
||||
} from "@/lib/skills/presentation";
|
||||
|
||||
type SkillSetupMessage = { kind: "success" | "error"; message: string };
|
||||
|
||||
type AgentSkillsSetupModalProps = {
|
||||
skill: SkillStatusEntry | null;
|
||||
skillsBusy: boolean;
|
||||
skillsBusyKey: string | null;
|
||||
skillMessage: SkillSetupMessage | null;
|
||||
apiKeyDraft: string;
|
||||
defaultAgentScopeWarning?: string | null;
|
||||
onClose: () => void;
|
||||
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
|
||||
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
|
||||
onRemoveSkill: (
|
||||
skill: { skillKey: string; source: string; baseDir: string }
|
||||
) => Promise<void> | void;
|
||||
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
|
||||
onSaveSkillApiKey: (skillKey: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const READINESS_LABELS = {
|
||||
ready: "Ready",
|
||||
"needs-setup": "Needs setup",
|
||||
unavailable: "Unavailable",
|
||||
"disabled-globally": "Disabled globally",
|
||||
} as const;
|
||||
|
||||
const READINESS_CLASSES = {
|
||||
ready: "ui-badge-status-running",
|
||||
"needs-setup": "ui-badge-status-error",
|
||||
unavailable: "ui-badge-status-error",
|
||||
"disabled-globally": "ui-badge-status-error",
|
||||
} as const;
|
||||
|
||||
export const AgentSkillsSetupModal = ({
|
||||
skill,
|
||||
skillsBusy,
|
||||
skillsBusyKey,
|
||||
skillMessage,
|
||||
apiKeyDraft,
|
||||
defaultAgentScopeWarning = null,
|
||||
onClose,
|
||||
onInstallSkill,
|
||||
onSetSkillGlobalEnabled,
|
||||
onRemoveSkill,
|
||||
onSkillApiKeyChange,
|
||||
onSaveSkillApiKey,
|
||||
}: AgentSkillsSetupModalProps) => {
|
||||
useEffect(() => {
|
||||
if (!skill) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, skill]);
|
||||
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const readiness = deriveSkillReadinessState(skill);
|
||||
const readinessLabel = READINESS_LABELS[readiness];
|
||||
const readinessClassName = READINESS_CLASSES[readiness];
|
||||
const missingDetails = buildSkillMissingDetails(skill);
|
||||
const installOption = resolvePreferredInstallOption(skill);
|
||||
const canDeleteSkill = canRemoveSkill(skill);
|
||||
const busyForSkill = skillsBusyKey === skill.skillKey;
|
||||
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
|
||||
const trimmedApiKey = apiKeyDraft.trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Setup ${skill.name}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="ui-panel w-full max-w-2xl bg-card shadow-xs"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-6 py-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80">
|
||||
System setup
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-semibold text-foreground">{skill.name}</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
|
||||
>
|
||||
{readinessLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-muted-foreground/80">
|
||||
Changes affect all agents on this gateway.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-6 pb-3 text-[11px] text-muted-foreground">
|
||||
{defaultAgentScopeWarning ? (
|
||||
<div className="rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/80">
|
||||
{defaultAgentScopeWarning}
|
||||
</div>
|
||||
) : null}
|
||||
<div>{skill.description}</div>
|
||||
{skill.blockedByAllowlist ? (
|
||||
<div className="text-[10px] text-muted-foreground/80">
|
||||
Blocked by bundled skills policy (`skills.allowBundled`).
|
||||
</div>
|
||||
) : null}
|
||||
{missingDetails.map((line) => (
|
||||
<div key={`${skill.skillKey}:${line}`} className="text-[10px] text-muted-foreground/80">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
{skillMessage ? (
|
||||
<div
|
||||
className={`text-[10px] ${skillMessage.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
|
||||
>
|
||||
{skillMessage.message}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
|
||||
{installOption ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
void onInstallSkill(skill.skillKey, skill.name, installOption.id);
|
||||
}}
|
||||
>
|
||||
{busyForSkill ? "Working..." : installOption.label}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
void onSetSkillGlobalEnabled(skill.skillKey, skill.disabled);
|
||||
}}
|
||||
>
|
||||
{busyForSkill
|
||||
? "Working..."
|
||||
: skill.disabled
|
||||
? "Enable globally"
|
||||
: "Disable globally"}
|
||||
</button>
|
||||
{skill.primaryEnv ? (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeyDraft}
|
||||
onChange={(event) => {
|
||||
void onSkillApiKeyChange(skill.skillKey, event.target.value);
|
||||
}}
|
||||
disabled={anySkillBusy}
|
||||
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[10px] text-foreground outline-none transition focus:border-border"
|
||||
placeholder={`Set ${skill.primaryEnv}`}
|
||||
aria-label={`API key for ${skill.name}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy || trimmedApiKey.length === 0}
|
||||
onClick={() => {
|
||||
if (trimmedApiKey.length === 0) {
|
||||
return;
|
||||
}
|
||||
void onSaveSkillApiKey(skill.skillKey);
|
||||
}}
|
||||
>
|
||||
{busyForSkill ? "Working..." : `Save ${skill.primaryEnv}`}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{canDeleteSkill ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary ui-btn-danger w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
const approved = window.confirm(
|
||||
`Remove ${skill.name} from the gateway? This affects all agents.`
|
||||
);
|
||||
if (!approved) {
|
||||
return;
|
||||
}
|
||||
void onRemoveSkill({
|
||||
skillKey: skill.skillKey,
|
||||
source: skill.source,
|
||||
baseDir: skill.baseDir,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Remove skill from gateway
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import { X } from "lucide-react";
|
||||
import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics";
|
||||
|
||||
type ConnectionPanelProps = {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
status: GatewayStatus;
|
||||
error: string | null;
|
||||
onGatewayUrlChange: (value: string) => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const ConnectionPanel = ({
|
||||
gatewayUrl,
|
||||
token,
|
||||
status,
|
||||
error,
|
||||
onGatewayUrlChange,
|
||||
onTokenChange,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onClose,
|
||||
}: ConnectionPanelProps) => {
|
||||
const isConnected = status === "connected";
|
||||
const isConnecting = status === "connecting";
|
||||
|
||||
return (
|
||||
<div className="fade-up-delay flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={`ui-chip inline-flex items-center px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass(status)}`}
|
||||
data-status={status}
|
||||
>
|
||||
{resolveGatewayStatusLabel(status)}
|
||||
</span>
|
||||
<button
|
||||
className="ui-btn-secondary px-4 py-2 text-xs font-semibold tracking-[0.05em] text-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={isConnected ? onDisconnect : onConnect}
|
||||
disabled={isConnecting || !gatewayUrl.trim()}
|
||||
>
|
||||
{isConnected ? "Disconnect" : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
{onClose ? (
|
||||
<button
|
||||
className="ui-btn-ghost inline-flex items-center gap-1 px-3 py-2 text-xs font-semibold tracking-[0.05em] text-foreground"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
data-testid="gateway-connection-close"
|
||||
aria-label="Close gateway connection panel"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Close
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-[1.4fr_1fr]">
|
||||
<label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||
Upstream URL
|
||||
<input
|
||||
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
|
||||
type="text"
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => onGatewayUrlChange(event.target.value)}
|
||||
placeholder="ws://localhost:18789"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||
Upstream token
|
||||
<input
|
||||
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(event) => onTokenChange(event.target.value)}
|
||||
placeholder="gateway token"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="ui-alert-danger rounded-md px-4 py-2 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||
|
||||
type EmptyStatePanelProps = {
|
||||
title: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
detail?: string;
|
||||
fillHeight?: boolean;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const EmptyStatePanel = ({
|
||||
title,
|
||||
label,
|
||||
description,
|
||||
detail,
|
||||
fillHeight = false,
|
||||
compact = false,
|
||||
className,
|
||||
}: EmptyStatePanelProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ui-card text-muted-foreground",
|
||||
fillHeight ? "flex h-full w-full flex-col justify-center" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label ? (
|
||||
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
"console-title mt-2 text-2xl leading-none text-foreground sm:text-3xl",
|
||||
compact ? "mt-0 text-xs font-medium tracking-normal text-muted-foreground sm:text-xs" : ""
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{description ? (
|
||||
<p className={cn("mt-3 text-sm text-muted-foreground", compact ? "mt-1 text-xs" : "")}>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{detail ? (
|
||||
<p className="ui-input mt-3 rounded-md px-4 py-2 font-mono text-[11px] text-muted-foreground/90">
|
||||
{detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { AgentState, FocusFilter } from "@/features/agents/state/store";
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import {
|
||||
NEEDS_APPROVAL_BADGE_CLASS,
|
||||
resolveAgentStatusBadgeClass,
|
||||
resolveAgentStatusLabel,
|
||||
} from "./colorSemantics";
|
||||
import { EmptyStatePanel } from "./EmptyStatePanel";
|
||||
|
||||
type FleetSidebarProps = {
|
||||
agents: AgentState[];
|
||||
selectedAgentId: string | null;
|
||||
filter: FocusFilter;
|
||||
onFilterChange: (next: FocusFilter) => void;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onCreateAgent: () => void;
|
||||
createDisabled?: boolean;
|
||||
createBusy?: boolean;
|
||||
};
|
||||
|
||||
const FILTER_OPTIONS: Array<{ value: FocusFilter; label: string; testId: string }> = [
|
||||
{ value: "all", label: "All", testId: "fleet-filter-all" },
|
||||
{ value: "running", label: "Running", testId: "fleet-filter-running" },
|
||||
{ value: "approvals", label: "Approvals", testId: "fleet-filter-approvals" },
|
||||
];
|
||||
|
||||
export const FleetSidebar = ({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
filter,
|
||||
onFilterChange,
|
||||
onSelectAgent,
|
||||
onCreateAgent,
|
||||
createDisabled = false,
|
||||
createBusy = false,
|
||||
}: FleetSidebarProps) => {
|
||||
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const previousTopByAgentIdRef = useRef<Map<string, number>>(new Map());
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const agentOrderKey = useMemo(() => agents.map((agent) => agent.agentId).join("|"), [agents]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scroller = scrollContainerRef.current;
|
||||
if (!scroller) return;
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
|
||||
const getTopInScrollContent = (node: HTMLElement) =>
|
||||
node.getBoundingClientRect().top - scrollerRect.top + scroller.scrollTop;
|
||||
|
||||
const nextTopByAgentId = new Map<string, number>();
|
||||
const agentIds = agentOrderKey.length === 0 ? [] : agentOrderKey.split("|");
|
||||
for (const agentId of agentIds) {
|
||||
const node = rowRefs.current.get(agentId);
|
||||
if (!node) continue;
|
||||
const nextTop = getTopInScrollContent(node);
|
||||
nextTopByAgentId.set(agentId, nextTop);
|
||||
const previousTop = previousTopByAgentIdRef.current.get(agentId);
|
||||
if (typeof previousTop !== "number") continue;
|
||||
const deltaY = previousTop - nextTop;
|
||||
if (Math.abs(deltaY) < 0.5) continue;
|
||||
if (typeof node.animate !== "function") continue;
|
||||
node.animate(
|
||||
[{ transform: `translateY(${deltaY}px)` }, { transform: "translateY(0px)" }],
|
||||
{ duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }
|
||||
);
|
||||
}
|
||||
previousTopByAgentIdRef.current = nextTopByAgentId;
|
||||
}, [agentOrderKey]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="glass-panel fade-up-delay ui-panel ui-depth-sidepanel relative flex h-full w-full min-w-72 flex-col gap-3 bg-sidebar p-3 xl:max-w-[320px] xl:border-r xl:border-sidebar-border"
|
||||
data-testid="fleet-sidebar"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-1">
|
||||
<p className="console-title type-page-title text-foreground">Agents ({agents.length})</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="fleet-new-agent-button"
|
||||
className="ui-btn-primary px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
|
||||
onClick={onCreateAgent}
|
||||
disabled={createDisabled || createBusy}
|
||||
>
|
||||
{createBusy ? "Creating..." : "New agent"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ui-segment ui-segment-fleet-filter grid-cols-3">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const active = filter === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
data-testid={option.testId}
|
||||
aria-pressed={active}
|
||||
className="ui-segment-item px-2 py-1 font-mono text-[12px] font-medium tracking-[0.02em]"
|
||||
data-active={active ? "true" : "false"}
|
||||
onClick={() => onFilterChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} className="ui-scroll min-h-0 flex-1 overflow-auto">
|
||||
{agents.length === 0 ? (
|
||||
<EmptyStatePanel title="No agents available." compact className="p-3 text-xs" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{agents.map((agent) => {
|
||||
const selected = selectedAgentId === agent.agentId;
|
||||
const avatarSeed = agent.avatarSeed ?? agent.agentId;
|
||||
return (
|
||||
<button
|
||||
key={agent.agentId}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
rowRefs.current.set(agent.agentId, node);
|
||||
return;
|
||||
}
|
||||
rowRefs.current.delete(agent.agentId);
|
||||
}}
|
||||
type="button"
|
||||
data-testid={`fleet-agent-row-${agent.agentId}`}
|
||||
className={`group relative ui-card flex w-full items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-colors ${
|
||||
selected
|
||||
? "ui-card-selected"
|
||||
: "hover:bg-surface-2/45"
|
||||
}`}
|
||||
onClick={() => onSelectAgent(agent.agentId)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`ui-card-select-indicator ${selected ? "opacity-100" : "opacity-0 group-hover:opacity-35"}`}
|
||||
/>
|
||||
<AgentAvatar
|
||||
seed={avatarSeed}
|
||||
name={agent.name}
|
||||
avatarUrl={agent.avatarUrl ?? null}
|
||||
size={42}
|
||||
isSelected={selected}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="type-secondary-heading truncate text-foreground">
|
||||
{agent.name}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`ui-badge ${resolveAgentStatusBadgeClass(agent.status)}`}
|
||||
data-status={agent.status}
|
||||
>
|
||||
{resolveAgentStatusLabel(agent.status)}
|
||||
</span>
|
||||
{agent.awaitingUserInput ? (
|
||||
<span className={`ui-badge ${NEEDS_APPROVAL_BADGE_CLASS}`} data-status="approval">
|
||||
Needs approval
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, Copy, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import type { StudioGatewaySettings } from "@/lib/studio/settings";
|
||||
|
||||
type GatewayConnectScreenProps = {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
localGatewayDefaults: StudioGatewaySettings | null;
|
||||
status: GatewayStatus;
|
||||
error: string | null;
|
||||
showApprovalHint: boolean;
|
||||
onGatewayUrlChange: (value: string) => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onUseLocalDefaults: () => void;
|
||||
onConnect: () => void;
|
||||
};
|
||||
|
||||
const resolveLocalGatewayPort = (gatewayUrl: string): number => {
|
||||
try {
|
||||
const parsed = new URL(gatewayUrl);
|
||||
const port = Number(parsed.port);
|
||||
if (Number.isFinite(port) && port > 0) return port;
|
||||
} catch {}
|
||||
return 18789;
|
||||
};
|
||||
|
||||
export const GatewayConnectScreen = ({
|
||||
gatewayUrl,
|
||||
token,
|
||||
localGatewayDefaults,
|
||||
status,
|
||||
error,
|
||||
showApprovalHint,
|
||||
onGatewayUrlChange,
|
||||
onTokenChange,
|
||||
onUseLocalDefaults,
|
||||
onConnect,
|
||||
}: GatewayConnectScreenProps) => {
|
||||
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const isLocal = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]);
|
||||
const localPort = useMemo(() => resolveLocalGatewayPort(gatewayUrl), [gatewayUrl]);
|
||||
const localGatewayCommand = useMemo(
|
||||
() => `npx openclaw gateway run --bind loopback --port ${localPort} --verbose`,
|
||||
[localPort]
|
||||
);
|
||||
const localGatewayCommandPnpm = useMemo(
|
||||
() => `pnpm openclaw gateway run --bind loopback --port ${localPort} --verbose`,
|
||||
[localPort]
|
||||
);
|
||||
const statusCopy = useMemo(() => {
|
||||
if (status === "connecting" && isLocal) {
|
||||
return `Local gateway detected on port ${localPort}. Connecting…`;
|
||||
}
|
||||
if (status === "connecting") {
|
||||
return "Connecting to remote gateway…";
|
||||
}
|
||||
if (isLocal) {
|
||||
return "No local gateway found.";
|
||||
}
|
||||
return "Not connected to a gateway.";
|
||||
}, [isLocal, localPort, status]);
|
||||
const connectDisabled = status === "connecting";
|
||||
const connectLabel = connectDisabled ? "Connecting…" : "Connect";
|
||||
const statusDotClass =
|
||||
status === "connected"
|
||||
? "ui-dot-status-connected"
|
||||
: status === "connecting"
|
||||
? "ui-dot-status-connecting"
|
||||
: "ui-dot-status-disconnected";
|
||||
|
||||
const copyLocalCommand = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(localGatewayCommand);
|
||||
setCopyStatus("copied");
|
||||
window.setTimeout(() => setCopyStatus("idle"), 1200);
|
||||
} catch {
|
||||
setCopyStatus("failed");
|
||||
window.setTimeout(() => setCopyStatus("idle"), 1800);
|
||||
}
|
||||
};
|
||||
|
||||
const commandField = (
|
||||
<div className="space-y-1.5">
|
||||
<div className="ui-command-surface flex items-center gap-2 rounded-md px-3 py-2">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12px] text-white">
|
||||
{localGatewayCommand}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-icon ui-command-copy h-7 w-7 shrink-0"
|
||||
onClick={copyLocalCommand}
|
||||
aria-label="Copy local gateway command"
|
||||
title="Copy command"
|
||||
>
|
||||
{copyStatus === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
{copyStatus === "copied" ? (
|
||||
<p className="text-xs text-white/80">Copied</p>
|
||||
) : copyStatus === "failed" ? (
|
||||
<p className="ui-text-danger text-xs">Could not copy command.</p>
|
||||
) : (
|
||||
<p className="text-xs leading-snug text-white/80">
|
||||
In a source checkout, use <span className="font-mono text-white">{localGatewayCommandPnpm}</span>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const remoteForm = (
|
||||
<div className="mt-2.5 flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1 text-[11px] font-medium text-white/90">
|
||||
Upstream URL
|
||||
<input
|
||||
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
|
||||
type="text"
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => onGatewayUrlChange(event.target.value)}
|
||||
placeholder="wss://your-gateway.example.com"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-0.5 text-xs text-white/80">
|
||||
<p className="font-medium text-white">Using Tailscale?</p>
|
||||
<p>
|
||||
URL: <span className="font-mono">wss://<your-tailnet-host></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-[11px] font-medium text-white/90">
|
||||
Upstream token
|
||||
<div className="relative">
|
||||
<input
|
||||
className="ui-input h-10 w-full rounded-md px-4 pr-10 font-sans text-sm text-foreground outline-none"
|
||||
type={showToken ? "text" : "password"}
|
||||
value={token}
|
||||
onChange={(event) => onTokenChange(event.target.value)}
|
||||
placeholder="gateway token"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-icon absolute inset-y-0 right-1 my-auto h-8 w-8 border-transparent bg-transparent text-white/70 hover:bg-transparent hover:text-white"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
onClick={() => setShowToken((prev) => !prev)}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4 transition-transform duration-150" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 transition-transform duration-150" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-primary mt-1 h-11 w-full px-4 text-xs font-semibold tracking-[0.05em] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={onConnect}
|
||||
disabled={connectDisabled || !gatewayUrl.trim()}
|
||||
>
|
||||
{connectLabel}
|
||||
</button>
|
||||
|
||||
{status === "connecting" ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-white/80">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Connecting…
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null}
|
||||
{showApprovalHint ? (
|
||||
<div className="rounded-md border border-white/10 bg-white/5 px-3 py-3 text-xs text-white/85">
|
||||
<p className="leading-snug">
|
||||
If the first connection attempt did not work, go to your OpenClaw computer and approve this
|
||||
device:
|
||||
</p>
|
||||
<code className="mt-2 block overflow-x-auto whitespace-nowrap rounded-md bg-black/30 px-2.5 py-2 font-mono text-[11px] text-white">
|
||||
openclaw devices approve --latest
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-[820px] flex-1 flex-col gap-5">
|
||||
<div className="ui-card px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{status === "connecting" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--status-connecting-fg)]" />
|
||||
) : (
|
||||
<span
|
||||
className={`h-2.5 w-2.5 ${statusDotClass}`}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-semibold text-white">{statusCopy}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-card px-4 py-5 sm:px-6">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] font-medium tracking-[0.06em] text-white/80">
|
||||
Remote gateway (recommended)
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-white/90">Default: enter your URL and token to connect.</p>
|
||||
</div>
|
||||
{remoteForm}
|
||||
</div>
|
||||
|
||||
<div className="ui-card px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-white/80">
|
||||
Run locally (optional)
|
||||
</p>
|
||||
<p className="text-sm text-white/90">
|
||||
Start a local gateway process on this machine, then connect.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{commandField}
|
||||
{localGatewayDefaults ? (
|
||||
<div className="ui-input rounded-md px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-white/80">
|
||||
Use token from <span className="font-mono">~/.openclaw/openclaw.json</span>.
|
||||
</p>
|
||||
<p className="font-mono text-[11px] text-white">
|
||||
{localGatewayDefaults.url}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary h-9 w-full px-3 text-xs font-semibold tracking-[0.05em] text-white"
|
||||
onClick={onUseLocalDefaults}
|
||||
>
|
||||
Use local defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import { Plug } from "lucide-react";
|
||||
import { resolveGatewayStatusBadgeClass } from "./colorSemantics";
|
||||
|
||||
type HeaderBarProps = {
|
||||
status: GatewayStatus;
|
||||
onConnectionSettings: () => void;
|
||||
showConnectionSettings?: boolean;
|
||||
};
|
||||
|
||||
export const HeaderBar = ({
|
||||
status,
|
||||
onConnectionSettings,
|
||||
showConnectionSettings = true,
|
||||
}: HeaderBarProps) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!menuRef.current) return;
|
||||
if (menuRef.current.contains(event.target as Node)) return;
|
||||
setMenuOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<div className="ui-topbar relative z-[180]">
|
||||
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3 sm:px-4 md:px-5">
|
||||
<div aria-hidden="true" />
|
||||
<p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground">Claw3D</p>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{status === "connecting" ? (
|
||||
<span
|
||||
className={`ui-chip px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass("connecting")}`}
|
||||
data-testid="gateway-connecting-indicator"
|
||||
data-status="connecting"
|
||||
>
|
||||
Connecting
|
||||
</span>
|
||||
) : null}
|
||||
<ThemeToggle />
|
||||
{showConnectionSettings ? (
|
||||
<div className="relative z-[210]" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-icon ui-btn-icon-xs"
|
||||
data-testid="studio-menu-toggle"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
>
|
||||
<Plug className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Open studio menu</span>
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<div className="ui-card ui-menu-popover absolute right-0 top-9 z-[260] min-w-44 p-1">
|
||||
<button
|
||||
className="ui-btn-ghost w-full justify-start border-transparent px-3 py-2 text-left text-xs font-medium tracking-normal text-foreground"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onConnectionSettings();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
data-testid="gateway-settings-toggle"
|
||||
>
|
||||
Gateway connection
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentSkillsSetupModal } from "@/features/agents/components/AgentSkillsSetupModal";
|
||||
import {
|
||||
buildSkillMissingDetails,
|
||||
deriveSkillReadinessState,
|
||||
type SkillReadinessState,
|
||||
} from "@/lib/skills/presentation";
|
||||
import type { SkillStatusReport } from "@/lib/skills/types";
|
||||
|
||||
type SkillSetupMessage = { kind: "success" | "error"; message: string };
|
||||
|
||||
type ReadinessFilter = "all" | SkillReadinessState;
|
||||
|
||||
type SystemSkillsPanelProps = {
|
||||
skillsReport?: SkillStatusReport | null;
|
||||
skillsLoading?: boolean;
|
||||
skillsError?: string | null;
|
||||
skillsBusy?: boolean;
|
||||
skillsBusyKey?: string | null;
|
||||
skillMessages?: Record<string, SkillSetupMessage>;
|
||||
skillApiKeyDrafts?: Record<string, string>;
|
||||
defaultAgentScopeWarning?: string | null;
|
||||
initialSkillKey?: string | null;
|
||||
onInitialSkillKeyHandled?: () => void;
|
||||
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
|
||||
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
|
||||
onRemoveSkill: (
|
||||
skill: { skillKey: string; source: string; baseDir: string }
|
||||
) => Promise<void> | void;
|
||||
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
|
||||
onSaveSkillApiKey: (skillKey: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const READINESS_FILTERS: Array<{ id: ReadinessFilter; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "ready", label: "Ready" },
|
||||
{ id: "needs-setup", label: "Needs setup" },
|
||||
{ id: "unavailable", label: "Unavailable" },
|
||||
{ id: "disabled-globally", label: "Disabled globally" },
|
||||
];
|
||||
|
||||
const READINESS_LABELS = {
|
||||
ready: "Ready",
|
||||
"needs-setup": "Needs setup",
|
||||
unavailable: "Unavailable",
|
||||
"disabled-globally": "Disabled globally",
|
||||
} as const;
|
||||
|
||||
const READINESS_CLASSES = {
|
||||
ready: "ui-badge-status-running",
|
||||
"needs-setup": "ui-badge-status-error",
|
||||
unavailable: "ui-badge-status-error",
|
||||
"disabled-globally": "ui-badge-status-error",
|
||||
} as const;
|
||||
|
||||
const resolveReadinessHint = (
|
||||
skill: SkillStatusReport["skills"][number],
|
||||
readiness: SkillReadinessState
|
||||
): string | null => {
|
||||
if (readiness === "ready") {
|
||||
return null;
|
||||
}
|
||||
if (readiness === "disabled-globally") {
|
||||
return "Disabled globally for all agents.";
|
||||
}
|
||||
if (readiness === "unavailable") {
|
||||
if (skill.blockedByAllowlist) {
|
||||
return "Blocked by bundled skills policy.";
|
||||
}
|
||||
return buildSkillMissingDetails(skill)[0] ?? "Unavailable on this system.";
|
||||
}
|
||||
return buildSkillMissingDetails(skill)[0] ?? "Requires setup.";
|
||||
};
|
||||
|
||||
export const SystemSkillsPanel = ({
|
||||
skillsReport = null,
|
||||
skillsLoading = false,
|
||||
skillsError = null,
|
||||
skillsBusy = false,
|
||||
skillsBusyKey = null,
|
||||
skillMessages = {},
|
||||
skillApiKeyDrafts = {},
|
||||
defaultAgentScopeWarning = null,
|
||||
initialSkillKey = null,
|
||||
onInitialSkillKeyHandled,
|
||||
onSetSkillGlobalEnabled,
|
||||
onInstallSkill,
|
||||
onRemoveSkill,
|
||||
onSkillApiKeyChange,
|
||||
onSaveSkillApiKey,
|
||||
}: SystemSkillsPanelProps) => {
|
||||
const [skillsFilter, setSkillsFilter] = useState("");
|
||||
const [readinessFilter, setReadinessFilter] = useState<ReadinessFilter>("all");
|
||||
const [setupSkillKey, setSetupSkillKey] = useState<string | null>(null);
|
||||
|
||||
const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]);
|
||||
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
|
||||
const requestedInitialSkillKey = useMemo(() => {
|
||||
const candidate = initialSkillKey?.trim() ?? "";
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
return skillEntries.some((entry) => entry.skillKey === candidate) ? candidate : null;
|
||||
}, [initialSkillKey, skillEntries]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
skillEntries.map((skill) => ({
|
||||
skill,
|
||||
readiness: deriveSkillReadinessState(skill),
|
||||
})),
|
||||
[skillEntries]
|
||||
);
|
||||
|
||||
const searchedRows = useMemo(() => {
|
||||
const query = skillsFilter.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((entry) =>
|
||||
[entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}, [rows, skillsFilter]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (readinessFilter === "all") {
|
||||
return searchedRows;
|
||||
}
|
||||
return searchedRows.filter((entry) => entry.readiness === readinessFilter);
|
||||
}, [readinessFilter, searchedRows]);
|
||||
|
||||
const readinessCounts = useMemo(
|
||||
() =>
|
||||
searchedRows.reduce(
|
||||
(counts, entry) => {
|
||||
counts.all += 1;
|
||||
counts[entry.readiness] += 1;
|
||||
return counts;
|
||||
},
|
||||
{
|
||||
all: 0,
|
||||
ready: 0,
|
||||
"needs-setup": 0,
|
||||
unavailable: 0,
|
||||
"disabled-globally": 0,
|
||||
} satisfies Record<ReadinessFilter, number>
|
||||
),
|
||||
[searchedRows]
|
||||
);
|
||||
|
||||
const setupQueue = useMemo(
|
||||
() =>
|
||||
rows.filter(
|
||||
(entry) => entry.readiness === "needs-setup" || entry.readiness === "disabled-globally"
|
||||
),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const selectedSkillKey = setupSkillKey ?? requestedInitialSkillKey;
|
||||
const selectedSetupSkill = selectedSkillKey
|
||||
? skillEntries.find((entry) => entry.skillKey === selectedSkillKey) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="sidebar-section" data-testid="agent-settings-system-skills">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="sidebar-section-title">System skill setup</h3>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{skillEntries.length}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
Changes here affect all agents on this gateway.
|
||||
</div>
|
||||
{defaultAgentScopeWarning ? (
|
||||
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/82">
|
||||
{defaultAgentScopeWarning}
|
||||
</div>
|
||||
) : null}
|
||||
{setupQueue.length > 0 ? (
|
||||
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
|
||||
<div className="text-[10px] font-semibold text-foreground/85">Needs setup ({setupQueue.length})</div>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{setupQueue.slice(0, 5).map((entry) => (
|
||||
<div
|
||||
key={`setup-queue:${entry.skill.skillKey}`}
|
||||
className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground/85"
|
||||
>
|
||||
<span className="truncate">{entry.skill.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
onInitialSkillKeyHandled?.();
|
||||
setSetupSkillKey(entry.skill.skillKey);
|
||||
}}
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
value={skillsFilter}
|
||||
onChange={(event) => setSkillsFilter(event.target.value)}
|
||||
placeholder="Search skills"
|
||||
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
|
||||
aria-label="Search skills"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{READINESS_FILTERS.map((filter) => {
|
||||
const selected = readinessFilter === filter.id;
|
||||
return (
|
||||
<button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
|
||||
data-active={selected ? "true" : "false"}
|
||||
disabled={skillsLoading}
|
||||
onClick={() => {
|
||||
setReadinessFilter(filter.id);
|
||||
}}
|
||||
>
|
||||
{filter.label} ({readinessCounts[filter.id]})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
|
||||
{!skillsLoading && skillsError ? (
|
||||
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
|
||||
) : null}
|
||||
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{filteredRows.map((entry) => {
|
||||
const readinessLabel = READINESS_LABELS[entry.readiness];
|
||||
const readinessClassName = READINESS_CLASSES[entry.readiness];
|
||||
const message = skillMessages[entry.skill.skillKey] ?? null;
|
||||
return (
|
||||
<div
|
||||
key={`${entry.skill.source}:${entry.skill.skillKey}`}
|
||||
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
|
||||
{entry.skill.source}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
|
||||
>
|
||||
{readinessLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
|
||||
{entry.readiness !== "ready" ? (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/80">
|
||||
{resolveReadinessHint(entry.skill, entry.readiness)}
|
||||
</div>
|
||||
) : null}
|
||||
{message ? (
|
||||
<div
|
||||
className={`mt-1 text-[10px] ${message.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
|
||||
>
|
||||
{message.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-2 sm:w-[210px]">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
onInitialSkillKeyHandled?.();
|
||||
setSetupSkillKey(entry.skill.skillKey);
|
||||
}}
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<AgentSkillsSetupModal
|
||||
skill={selectedSetupSkill}
|
||||
skillsBusy={skillsBusy}
|
||||
skillsBusyKey={skillsBusyKey}
|
||||
skillMessage={selectedSetupSkill ? skillMessages[selectedSetupSkill.skillKey] ?? null : null}
|
||||
apiKeyDraft={selectedSetupSkill ? skillApiKeyDrafts[selectedSetupSkill.skillKey] ?? "" : ""}
|
||||
defaultAgentScopeWarning={defaultAgentScopeWarning}
|
||||
onClose={() => {
|
||||
onInitialSkillKeyHandled?.();
|
||||
setSetupSkillKey(null);
|
||||
}}
|
||||
onInstallSkill={onInstallSkill}
|
||||
onSetSkillGlobalEnabled={onSetSkillGlobalEnabled}
|
||||
onRemoveSkill={onRemoveSkill}
|
||||
onSkillApiKeyChange={onSkillApiKeyChange}
|
||||
onSaveSkillApiKey={onSaveSkillApiKey}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,493 @@
|
||||
import {
|
||||
formatThinkingMarkdown,
|
||||
isToolMarkdown,
|
||||
isMetaMarkdown,
|
||||
isTraceMarkdown,
|
||||
parseToolMarkdown,
|
||||
parseMetaMarkdown,
|
||||
stripTraceMarkdown,
|
||||
} from "@/lib/text/message-extract";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
|
||||
type ItemMeta = {
|
||||
role: "user" | "assistant";
|
||||
timestampMs: number;
|
||||
thinkingDurationMs?: number;
|
||||
};
|
||||
|
||||
export type AgentChatItem =
|
||||
| { kind: "user"; text: string; timestampMs?: number }
|
||||
| { kind: "assistant"; text: string; live?: boolean; timestampMs?: number; thinkingDurationMs?: number }
|
||||
| { kind: "tool"; text: string; timestampMs?: number }
|
||||
| { kind: "thinking"; text: string; live?: boolean; timestampMs?: number; thinkingDurationMs?: number };
|
||||
|
||||
export type AssistantTraceEvent =
|
||||
| { kind: "thinking"; text: string }
|
||||
| { kind: "tool"; text: string };
|
||||
|
||||
export type AgentChatRenderBlock =
|
||||
| { kind: "user"; text: string; timestampMs?: number }
|
||||
| {
|
||||
kind: "assistant";
|
||||
text: string | null;
|
||||
timestampMs?: number;
|
||||
thinkingDurationMs?: number;
|
||||
traceEvents: AssistantTraceEvent[];
|
||||
};
|
||||
|
||||
export type BuildAgentChatItemsInput = {
|
||||
outputLines: string[];
|
||||
streamText: string | null;
|
||||
liveThinkingTrace: string;
|
||||
showThinkingTraces: boolean;
|
||||
toolCallingEnabled: boolean;
|
||||
};
|
||||
|
||||
const normalizeUserDisplayText = (value: string): string => {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
};
|
||||
|
||||
const normalizeThinkingDisplayText = (value: string): string => {
|
||||
const markdown = formatThinkingMarkdown(value);
|
||||
const normalized = stripTraceMarkdown(markdown).trim();
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const buildFinalAgentChatItems = ({
|
||||
outputLines,
|
||||
showThinkingTraces,
|
||||
toolCallingEnabled,
|
||||
}: Pick<
|
||||
BuildAgentChatItemsInput,
|
||||
"outputLines" | "showThinkingTraces" | "toolCallingEnabled"
|
||||
>): AgentChatItem[] => {
|
||||
const items: AgentChatItem[] = [];
|
||||
let currentMeta: ItemMeta | null = null;
|
||||
const appendThinking = (text: string) => {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) return;
|
||||
const previous = items[items.length - 1];
|
||||
if (!previous || previous.kind !== "thinking") {
|
||||
items.push({
|
||||
kind: "thinking",
|
||||
text: normalized,
|
||||
...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (previous.text === normalized) {
|
||||
return;
|
||||
}
|
||||
if (normalized.startsWith(previous.text)) {
|
||||
previous.text = normalized;
|
||||
return;
|
||||
}
|
||||
if (previous.text.startsWith(normalized)) {
|
||||
return;
|
||||
}
|
||||
previous.text = `${previous.text}\n\n${normalized}`;
|
||||
};
|
||||
|
||||
for (const line of outputLines) {
|
||||
if (!line) continue;
|
||||
if (isMetaMarkdown(line)) {
|
||||
const parsed = parseMetaMarkdown(line);
|
||||
if (parsed) {
|
||||
currentMeta = {
|
||||
role: parsed.role,
|
||||
timestampMs: parsed.timestamp,
|
||||
...(typeof parsed.thinkingDurationMs === "number" ? { thinkingDurationMs: parsed.thinkingDurationMs } : {}),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isTraceMarkdown(line)) {
|
||||
if (!showThinkingTraces) continue;
|
||||
const text = stripTraceMarkdown(line).trim();
|
||||
if (!text) continue;
|
||||
appendThinking(text);
|
||||
continue;
|
||||
}
|
||||
if (isToolMarkdown(line)) {
|
||||
if (!toolCallingEnabled) continue;
|
||||
items.push({
|
||||
kind: "tool",
|
||||
text: line,
|
||||
...(currentMeta ? { timestampMs: currentMeta.timestampMs } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith(">")) {
|
||||
const text = trimmed.replace(/^>\s?/, "").trim();
|
||||
if (text) {
|
||||
const normalized = normalizeUserDisplayText(text);
|
||||
const currentTimestamp =
|
||||
currentMeta?.role === "user" ? currentMeta.timestampMs : undefined;
|
||||
const previous = items[items.length - 1];
|
||||
if (previous?.kind === "user") {
|
||||
const previousNormalized = normalizeUserDisplayText(previous.text);
|
||||
const previousTimestamp = previous.timestampMs;
|
||||
const shouldCollapse =
|
||||
previousNormalized === normalized &&
|
||||
((typeof previousTimestamp === "number" &&
|
||||
typeof currentTimestamp === "number" &&
|
||||
previousTimestamp === currentTimestamp) ||
|
||||
(previousTimestamp === undefined &&
|
||||
typeof currentTimestamp === "number"));
|
||||
if (
|
||||
shouldCollapse
|
||||
) {
|
||||
previous.text = normalized;
|
||||
if (typeof currentTimestamp === "number") {
|
||||
previous.timestampMs = currentTimestamp;
|
||||
}
|
||||
if (currentMeta?.role === "user") {
|
||||
currentMeta = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
kind: "user",
|
||||
text: normalized,
|
||||
...(typeof currentTimestamp === "number" ? { timestampMs: currentTimestamp } : {}),
|
||||
});
|
||||
if (currentMeta?.role === "user") {
|
||||
currentMeta = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const normalizedAssistant = normalizeAssistantDisplayText(line);
|
||||
if (!normalizedAssistant) continue;
|
||||
items.push({
|
||||
kind: "assistant",
|
||||
text: normalizedAssistant,
|
||||
...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
export const buildAgentChatItems = ({
|
||||
outputLines,
|
||||
streamText,
|
||||
liveThinkingTrace,
|
||||
showThinkingTraces,
|
||||
toolCallingEnabled,
|
||||
}: BuildAgentChatItemsInput): AgentChatItem[] => {
|
||||
const items: AgentChatItem[] = [];
|
||||
let currentMeta: ItemMeta | null = null;
|
||||
const appendThinking = (text: string, live?: boolean) => {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) return;
|
||||
const previous = items[items.length - 1];
|
||||
if (!previous || previous.kind !== "thinking") {
|
||||
items.push({
|
||||
kind: "thinking",
|
||||
text: normalized,
|
||||
live,
|
||||
...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (previous.text === normalized) {
|
||||
if (live) previous.live = true;
|
||||
return;
|
||||
}
|
||||
if (normalized.startsWith(previous.text)) {
|
||||
previous.text = normalized;
|
||||
if (live) previous.live = true;
|
||||
return;
|
||||
}
|
||||
if (previous.text.startsWith(normalized)) {
|
||||
if (live) previous.live = true;
|
||||
return;
|
||||
}
|
||||
previous.text = `${previous.text}\n\n${normalized}`;
|
||||
if (live) previous.live = true;
|
||||
};
|
||||
|
||||
for (const line of outputLines) {
|
||||
if (!line) continue;
|
||||
if (isMetaMarkdown(line)) {
|
||||
const parsed = parseMetaMarkdown(line);
|
||||
if (parsed) {
|
||||
currentMeta = {
|
||||
role: parsed.role,
|
||||
timestampMs: parsed.timestamp,
|
||||
...(typeof parsed.thinkingDurationMs === "number" ? { thinkingDurationMs: parsed.thinkingDurationMs } : {}),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isTraceMarkdown(line)) {
|
||||
if (!showThinkingTraces) continue;
|
||||
const text = stripTraceMarkdown(line).trim();
|
||||
if (!text) continue;
|
||||
appendThinking(text);
|
||||
continue;
|
||||
}
|
||||
if (isToolMarkdown(line)) {
|
||||
if (!toolCallingEnabled) continue;
|
||||
items.push({
|
||||
kind: "tool",
|
||||
text: line,
|
||||
...(currentMeta ? { timestampMs: currentMeta.timestampMs } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith(">")) {
|
||||
const text = trimmed.replace(/^>\s?/, "").trim();
|
||||
if (text) {
|
||||
const currentTimestamp =
|
||||
currentMeta?.role === "user" ? currentMeta.timestampMs : undefined;
|
||||
items.push({
|
||||
kind: "user",
|
||||
text: normalizeUserDisplayText(text),
|
||||
...(typeof currentTimestamp === "number" ? { timestampMs: currentTimestamp } : {}),
|
||||
});
|
||||
if (currentMeta?.role === "user") {
|
||||
currentMeta = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const normalizedAssistant = normalizeAssistantDisplayText(line);
|
||||
if (!normalizedAssistant) continue;
|
||||
items.push({
|
||||
kind: "assistant",
|
||||
text: normalizedAssistant,
|
||||
...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const liveStream = streamText?.trim();
|
||||
|
||||
if (showThinkingTraces) {
|
||||
const normalizedLiveThinking = normalizeThinkingDisplayText(liveThinkingTrace);
|
||||
if (normalizedLiveThinking) {
|
||||
appendThinking(normalizedLiveThinking, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (liveStream) {
|
||||
const normalizedStream = normalizeAssistantDisplayText(liveStream);
|
||||
if (normalizedStream) {
|
||||
items.push({ kind: "assistant", text: normalizedStream, live: true });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const mergeIncrementalText = (existing: string, next: string): string => {
|
||||
if (existing === next) return existing;
|
||||
if (next.startsWith(existing)) return next;
|
||||
if (existing.startsWith(next)) return existing;
|
||||
return `${existing}\n\n${next}`;
|
||||
};
|
||||
|
||||
const appendThinkingTraceEvent = (events: AssistantTraceEvent[], text: string) => {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) return;
|
||||
const previous = events[events.length - 1];
|
||||
if (!previous || previous.kind !== "thinking") {
|
||||
events.push({ kind: "thinking", text: normalized });
|
||||
return;
|
||||
}
|
||||
previous.text = mergeIncrementalText(previous.text, normalized);
|
||||
};
|
||||
|
||||
const hasMismatchedTimestamps = (
|
||||
left?: number,
|
||||
right?: number
|
||||
): boolean => {
|
||||
if (typeof left !== "number" || typeof right !== "number") return false;
|
||||
return left !== right;
|
||||
};
|
||||
|
||||
export const buildAgentChatRenderBlocks = (
|
||||
chatItems: AgentChatItem[]
|
||||
): AgentChatRenderBlock[] => {
|
||||
const blocks: AgentChatRenderBlock[] = [];
|
||||
let currentAssistant: Extract<AgentChatRenderBlock, { kind: "assistant" }> | null = null;
|
||||
|
||||
const flushAssistant = () => {
|
||||
if (!currentAssistant) return;
|
||||
if (currentAssistant.text || currentAssistant.traceEvents.length > 0) {
|
||||
blocks.push(currentAssistant);
|
||||
}
|
||||
currentAssistant = null;
|
||||
};
|
||||
|
||||
const ensureAssistant = (meta?: {
|
||||
timestampMs?: number;
|
||||
thinkingDurationMs?: number;
|
||||
}) => {
|
||||
if (!currentAssistant) {
|
||||
currentAssistant = {
|
||||
kind: "assistant",
|
||||
text: null,
|
||||
traceEvents: [],
|
||||
...(typeof meta?.timestampMs === "number" ? { timestampMs: meta.timestampMs } : {}),
|
||||
...(typeof meta?.thinkingDurationMs === "number"
|
||||
? { thinkingDurationMs: meta.thinkingDurationMs }
|
||||
: {}),
|
||||
};
|
||||
return currentAssistant;
|
||||
}
|
||||
if (
|
||||
currentAssistant.text &&
|
||||
hasMismatchedTimestamps(currentAssistant.timestampMs, meta?.timestampMs)
|
||||
) {
|
||||
flushAssistant();
|
||||
currentAssistant = {
|
||||
kind: "assistant",
|
||||
text: null,
|
||||
traceEvents: [],
|
||||
...(typeof meta?.timestampMs === "number" ? { timestampMs: meta.timestampMs } : {}),
|
||||
...(typeof meta?.thinkingDurationMs === "number"
|
||||
? { thinkingDurationMs: meta.thinkingDurationMs }
|
||||
: {}),
|
||||
};
|
||||
return currentAssistant;
|
||||
}
|
||||
if (
|
||||
typeof currentAssistant.timestampMs !== "number" &&
|
||||
typeof meta?.timestampMs === "number"
|
||||
) {
|
||||
currentAssistant.timestampMs = meta.timestampMs;
|
||||
}
|
||||
if (typeof meta?.thinkingDurationMs === "number") {
|
||||
currentAssistant.thinkingDurationMs = meta.thinkingDurationMs;
|
||||
}
|
||||
return currentAssistant;
|
||||
};
|
||||
|
||||
for (const item of chatItems) {
|
||||
if (item.kind === "user") {
|
||||
flushAssistant();
|
||||
blocks.push({ kind: "user", text: item.text, timestampMs: item.timestampMs });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind === "thinking") {
|
||||
const assistant = ensureAssistant({
|
||||
timestampMs: item.timestampMs,
|
||||
thinkingDurationMs: item.thinkingDurationMs,
|
||||
});
|
||||
appendThinkingTraceEvent(assistant.traceEvents, item.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind === "tool") {
|
||||
const assistant = ensureAssistant({ timestampMs: item.timestampMs });
|
||||
assistant.traceEvents.push({ kind: "tool", text: item.text });
|
||||
continue;
|
||||
}
|
||||
|
||||
const assistant = ensureAssistant({
|
||||
timestampMs: item.timestampMs,
|
||||
thinkingDurationMs: item.thinkingDurationMs,
|
||||
});
|
||||
const normalized = item.text.trim();
|
||||
if (!normalized) continue;
|
||||
assistant.text =
|
||||
typeof assistant.text === "string"
|
||||
? mergeIncrementalText(assistant.text, normalized)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
flushAssistant();
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const stripTrailingToolCallId = (
|
||||
label: string
|
||||
): { toolLabel: string; toolCallId: string | null } => {
|
||||
const trimmed = label.trim();
|
||||
const match = trimmed.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
|
||||
if (!match) return { toolLabel: trimmed, toolCallId: null };
|
||||
const toolLabel = (match[1] ?? "").trim();
|
||||
const toolCallId = (match[2] ?? "").trim();
|
||||
return { toolLabel: toolLabel || trimmed, toolCallId: toolCallId || null };
|
||||
};
|
||||
|
||||
const toDisplayToolName = (label: string): string => {
|
||||
const cleaned = label.trim();
|
||||
if (!cleaned) return "tool";
|
||||
const segments = cleaned.split(/[.:/]/).map((s) => s.trim()).filter(Boolean);
|
||||
return segments[segments.length - 1] ?? cleaned;
|
||||
};
|
||||
|
||||
const truncateInline = (value: string, maxChars: number): string => {
|
||||
const cleaned = value.replace(/\s+/g, " ").trim();
|
||||
if (cleaned.length <= maxChars) return cleaned;
|
||||
return `${cleaned.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
||||
};
|
||||
|
||||
const extractToolMetaLine = (body: string): string | null => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return null;
|
||||
const [firstLine] = trimmed.split(/\r?\n/, 1);
|
||||
const meta = (firstLine ?? "").trim();
|
||||
if (!meta) return null;
|
||||
if (meta.startsWith("```")) return null;
|
||||
return meta;
|
||||
};
|
||||
|
||||
const extractFirstCodeBlockLine = (body: string): string | null => {
|
||||
const match = body.match(/```[a-zA-Z0-9_-]*\r?\n([^\r\n]+)\r?\n/);
|
||||
const line = (match?.[1] ?? "").trim();
|
||||
return line ? truncateInline(line, 96) : null;
|
||||
};
|
||||
|
||||
const extractToolArgSummary = (body: string): string | null => {
|
||||
const matchers: Array<[RegExp, (m: RegExpMatchArray) => string | null]> = [
|
||||
[/"command"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
[/"file_path"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
[/"filePath"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
[/"path"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
[/"url"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
|
||||
];
|
||||
for (const [re, toSummary] of matchers) {
|
||||
const m = body.match(re);
|
||||
const summary = m ? toSummary(m) : null;
|
||||
if (summary) return truncateInline(summary, 96);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const summarizeToolLabel = (
|
||||
line: string
|
||||
): { summaryText: string; body: string; inlineOnly?: boolean } => {
|
||||
const parsed = parseToolMarkdown(line);
|
||||
const { toolLabel } = stripTrailingToolCallId(parsed.label);
|
||||
const toolName = toDisplayToolName(toolLabel).toUpperCase();
|
||||
const metaLine = parsed.kind === "result" ? extractToolMetaLine(parsed.body) : null;
|
||||
const argSummary = parsed.kind === "call" ? extractToolArgSummary(parsed.body) : null;
|
||||
const toolIsRead = toolName === "READ";
|
||||
if (toolIsRead && parsed.kind === "call" && argSummary) {
|
||||
return {
|
||||
summaryText: `read ${argSummary}`,
|
||||
body: "",
|
||||
inlineOnly: true,
|
||||
};
|
||||
}
|
||||
const suffix = metaLine ?? argSummary;
|
||||
const toolIsExec = toolName === "EXEC";
|
||||
const execSummary =
|
||||
parsed.kind === "call"
|
||||
? argSummary
|
||||
: metaLine ?? extractFirstCodeBlockLine(parsed.body);
|
||||
const summaryText = toolIsExec
|
||||
? (execSummary ?? metaLine ?? toolName)
|
||||
: (suffix ? `${toolName} · ${suffix}` : toolName);
|
||||
return {
|
||||
summaryText,
|
||||
body: parsed.body,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AgentStatus } from "@/features/agents/state/store";
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
|
||||
idle: "Idle",
|
||||
running: "Running",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
export const AGENT_STATUS_BADGE_CLASS: Record<AgentStatus, string> = {
|
||||
idle: "ui-badge-status-idle",
|
||||
running: "ui-badge-status-running",
|
||||
error: "ui-badge-status-error",
|
||||
};
|
||||
|
||||
export const GATEWAY_STATUS_LABEL: Record<GatewayStatus, string> = {
|
||||
disconnected: "Disconnected",
|
||||
connecting: "Connecting",
|
||||
connected: "Connected",
|
||||
};
|
||||
|
||||
export const GATEWAY_STATUS_BADGE_CLASS: Record<GatewayStatus, string> = {
|
||||
disconnected: "ui-badge-status-disconnected",
|
||||
connecting: "ui-badge-status-connecting",
|
||||
connected: "ui-badge-status-connected",
|
||||
};
|
||||
|
||||
export const NEEDS_APPROVAL_BADGE_CLASS = "ui-badge-approval";
|
||||
|
||||
export const resolveAgentStatusBadgeClass = (status: AgentStatus): string =>
|
||||
AGENT_STATUS_BADGE_CLASS[status];
|
||||
|
||||
export const resolveGatewayStatusBadgeClass = (status: GatewayStatus): string =>
|
||||
GATEWAY_STATUS_BADGE_CLASS[status];
|
||||
|
||||
export const resolveAgentStatusLabel = (status: AgentStatus): string => AGENT_STATUS_LABEL[status];
|
||||
|
||||
export const resolveGatewayStatusLabel = (status: GatewayStatus): string => GATEWAY_STATUS_LABEL[status];
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, type ReactNode } from "react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder";
|
||||
import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor";
|
||||
|
||||
export type AgentBrainPanelProps = {
|
||||
client: GatewayClient;
|
||||
agents: AgentState[];
|
||||
selectedAgentId: string | null;
|
||||
onUnsavedChangesChange?: (dirty: boolean) => void;
|
||||
};
|
||||
|
||||
const AgentBrainPanelSection = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<section className="space-y-3 border-t border-border/55 pt-8 first:border-t-0 first:pt-0">
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
export const AgentBrainPanel = ({
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onUnsavedChangesChange,
|
||||
}: AgentBrainPanelProps) => {
|
||||
const selectedAgent = useMemo(
|
||||
() =>
|
||||
selectedAgentId
|
||||
? agents.find((entry) => entry.agentId === selectedAgentId) ?? null
|
||||
: null,
|
||||
[agents, selectedAgentId]
|
||||
);
|
||||
|
||||
const {
|
||||
agentFiles,
|
||||
agentFilesLoading,
|
||||
agentFilesSaving,
|
||||
agentFilesDirty,
|
||||
agentFilesError,
|
||||
setAgentFileContent,
|
||||
saveAgentFiles,
|
||||
discardAgentFileChanges,
|
||||
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
|
||||
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
|
||||
|
||||
const setIdentityField = useCallback(
|
||||
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
|
||||
const nextDraft = parsePersonalityFiles(agentFiles);
|
||||
nextDraft.identity[field] = value;
|
||||
const serialized = serializePersonalityFiles(nextDraft);
|
||||
setAgentFileContent("IDENTITY.md", serialized["IDENTITY.md"]);
|
||||
},
|
||||
[agentFiles, setAgentFileContent]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (agentFilesLoading || agentFilesSaving || !agentFilesDirty) return;
|
||||
await saveAgentFiles();
|
||||
}, [agentFilesDirty, agentFilesLoading, agentFilesSaving, saveAgentFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
onUnsavedChangesChange?.(agentFilesDirty);
|
||||
}, [agentFilesDirty, onUnsavedChangesChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onUnsavedChangesChange?.(false);
|
||||
};
|
||||
}, [onUnsavedChangesChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="agent-inspect-panel flex min-h-0 flex-col overflow-hidden"
|
||||
data-testid="agent-personality-panel"
|
||||
style={{ position: "relative", left: "auto", top: "auto", width: "100%", height: "100%" }}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
|
||||
<section
|
||||
className="mx-auto flex min-h-0 w-full max-w-[920px] flex-col"
|
||||
data-testid="agent-personality-files"
|
||||
>
|
||||
{agentFilesError ? (
|
||||
<div className="ui-alert-danger mb-4 rounded-md px-3 py-2 text-xs">
|
||||
{agentFilesError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:opacity-50"
|
||||
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
|
||||
onClick={discardAgentFileChanges}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-primary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
|
||||
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
|
||||
onClick={() => {
|
||||
void handleSave();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 pb-8">
|
||||
<AgentBrainPanelSection title="Persona">
|
||||
<textarea
|
||||
aria-label="Persona"
|
||||
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
||||
value={agentFiles["SOUL.md"].content}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setAgentFileContent("SOUL.md", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</AgentBrainPanelSection>
|
||||
|
||||
<AgentBrainPanelSection title="Directives">
|
||||
<textarea
|
||||
aria-label="Directives"
|
||||
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
||||
value={agentFiles["AGENTS.md"].content}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setAgentFileContent("AGENTS.md", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</AgentBrainPanelSection>
|
||||
|
||||
<AgentBrainPanelSection title="Context">
|
||||
<textarea
|
||||
aria-label="Context"
|
||||
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
||||
value={agentFiles["USER.md"].content}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setAgentFileContent("USER.md", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</AgentBrainPanelSection>
|
||||
|
||||
<section className="space-y-3 border-t border-border/55 pt-8">
|
||||
<h3 className="text-sm font-medium text-foreground">Identity</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||
Name
|
||||
<input
|
||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
||||
value={draft.identity.name}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setIdentityField("name", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||
Creature
|
||||
<input
|
||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
||||
value={draft.identity.creature}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setIdentityField("creature", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||
Vibe
|
||||
<input
|
||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
||||
value={draft.identity.vibe}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setIdentityField("vibe", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||
Emoji
|
||||
<input
|
||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
||||
value={draft.identity.emoji}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setIdentityField("emoji", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||
Avatar
|
||||
<input
|
||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
||||
value={draft.identity.avatar}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setIdentityField("avatar", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export type AgentInspectHeaderProps = {
|
||||
label?: string;
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
closeTestId: string;
|
||||
closeDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const AgentInspectHeader = ({
|
||||
label,
|
||||
title,
|
||||
onClose,
|
||||
closeTestId,
|
||||
closeDisabled,
|
||||
}: AgentInspectHeaderProps) => {
|
||||
const normalizedLabel = label?.trim() ?? "";
|
||||
const normalizedTitle = title?.trim() ?? "";
|
||||
const hasLabel = normalizedLabel.length > 0;
|
||||
const hasTitle = normalizedTitle.length > 0;
|
||||
|
||||
if (!hasLabel && !hasTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between pl-4 pr-2 pb-3 pt-2">
|
||||
<div>
|
||||
{hasLabel ? (
|
||||
<div className="font-mono text-[9px] font-medium tracking-[0.04em] text-muted-foreground/58">
|
||||
{normalizedLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{hasTitle ? (
|
||||
<div
|
||||
className={
|
||||
hasLabel
|
||||
? "text-[1.45rem] font-semibold leading-[1.05] tracking-[0.01em] text-foreground"
|
||||
: "font-mono text-[12px] font-semibold tracking-[0.05em] text-foreground"
|
||||
}
|
||||
>
|
||||
{normalizedTitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground/55 transition hover:bg-surface-2 hover:text-muted-foreground/85"
|
||||
type="button"
|
||||
data-testid={closeTestId}
|
||||
aria-label="Close panel"
|
||||
disabled={closeDisabled}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
export type AgentCreateModalSubmitPayload = {
|
||||
name: string;
|
||||
avatarSeed?: string;
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { readGatewayAgentFile, writeGatewayAgentFile } from "@/lib/gateway/agentFiles";
|
||||
import {
|
||||
AGENT_FILE_NAMES,
|
||||
type AgentFileName,
|
||||
createAgentFilesState,
|
||||
isAgentFileName,
|
||||
} from "@/lib/agents/agentFiles";
|
||||
|
||||
type AgentFilesState = ReturnType<typeof createAgentFilesState>;
|
||||
|
||||
export type UseAgentFilesEditorResult = {
|
||||
agentFiles: AgentFilesState;
|
||||
agentFilesLoading: boolean;
|
||||
agentFilesSaving: boolean;
|
||||
agentFilesDirty: boolean;
|
||||
agentFilesError: string | null;
|
||||
setAgentFileContent: (name: AgentFileName, value: string) => void;
|
||||
saveAgentFiles: () => Promise<boolean>;
|
||||
discardAgentFileChanges: () => void;
|
||||
};
|
||||
|
||||
export const useAgentFilesEditor = (params: {
|
||||
client: GatewayClient | null | undefined;
|
||||
agentId: string | null | undefined;
|
||||
}): UseAgentFilesEditorResult => {
|
||||
const { client, agentId } = params;
|
||||
const [agentFiles, setAgentFiles] = useState(createAgentFilesState);
|
||||
const [agentFilesLoading, setAgentFilesLoading] = useState(false);
|
||||
const [agentFilesSaving, setAgentFilesSaving] = useState(false);
|
||||
const [agentFilesDirty, setAgentFilesDirty] = useState(false);
|
||||
const [agentFilesError, setAgentFilesError] = useState<string | null>(null);
|
||||
const savedAgentFilesRef = useRef<AgentFilesState>(createAgentFilesState());
|
||||
|
||||
const cloneAgentFilesState = useCallback((source: AgentFilesState): AgentFilesState => {
|
||||
const next = createAgentFilesState();
|
||||
for (const name of AGENT_FILE_NAMES) {
|
||||
next[name] = { ...source[name] };
|
||||
}
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const loadAgentFiles = useCallback(async () => {
|
||||
setAgentFilesLoading(true);
|
||||
setAgentFilesError(null);
|
||||
|
||||
try {
|
||||
const trimmedAgentId = agentId?.trim();
|
||||
if (!trimmedAgentId) {
|
||||
const emptyState = createAgentFilesState();
|
||||
savedAgentFilesRef.current = emptyState;
|
||||
setAgentFiles(emptyState);
|
||||
setAgentFilesDirty(false);
|
||||
setAgentFilesError("Agent ID is missing for this agent.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
setAgentFilesError("Gateway client is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
AGENT_FILE_NAMES.map(async (name) => {
|
||||
const file = await readGatewayAgentFile({ client, agentId: trimmedAgentId, name });
|
||||
return { name, content: file.content, exists: file.exists };
|
||||
})
|
||||
);
|
||||
|
||||
const nextState = createAgentFilesState();
|
||||
for (const file of results) {
|
||||
if (!isAgentFileName(file.name)) continue;
|
||||
nextState[file.name] = {
|
||||
content: file.content ?? "",
|
||||
exists: Boolean(file.exists),
|
||||
};
|
||||
}
|
||||
|
||||
savedAgentFilesRef.current = nextState;
|
||||
setAgentFiles(nextState);
|
||||
setAgentFilesDirty(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load agent files.";
|
||||
setAgentFilesError(message);
|
||||
} finally {
|
||||
setAgentFilesLoading(false);
|
||||
}
|
||||
}, [agentId, client]);
|
||||
|
||||
const saveAgentFiles = useCallback(async () => {
|
||||
setAgentFilesSaving(true);
|
||||
setAgentFilesError(null);
|
||||
|
||||
try {
|
||||
const trimmedAgentId = agentId?.trim();
|
||||
if (!trimmedAgentId) {
|
||||
setAgentFilesError("Agent ID is missing for this agent.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
setAgentFilesError("Gateway client is not available.");
|
||||
return false;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
AGENT_FILE_NAMES.map(async (name) => {
|
||||
await writeGatewayAgentFile({
|
||||
client,
|
||||
agentId: trimmedAgentId,
|
||||
name,
|
||||
content: agentFiles[name].content,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const nextState = createAgentFilesState();
|
||||
for (const name of AGENT_FILE_NAMES) {
|
||||
nextState[name] = {
|
||||
content: agentFiles[name].content,
|
||||
exists: true,
|
||||
};
|
||||
}
|
||||
|
||||
savedAgentFilesRef.current = nextState;
|
||||
setAgentFiles(nextState);
|
||||
setAgentFilesDirty(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to save agent files.";
|
||||
setAgentFilesError(message);
|
||||
return false;
|
||||
} finally {
|
||||
setAgentFilesSaving(false);
|
||||
}
|
||||
}, [agentFiles, agentId, client]);
|
||||
|
||||
const setAgentFileContent = useCallback((name: AgentFileName, value: string) => {
|
||||
if (!isAgentFileName(name)) return;
|
||||
|
||||
setAgentFiles((prev) => ({
|
||||
...prev,
|
||||
[name]: { ...prev[name], content: value },
|
||||
}));
|
||||
setAgentFilesDirty(true);
|
||||
}, []);
|
||||
|
||||
const discardAgentFileChanges = useCallback(() => {
|
||||
setAgentFiles(cloneAgentFilesState(savedAgentFilesRef.current));
|
||||
setAgentFilesDirty(false);
|
||||
setAgentFilesError(null);
|
||||
}, [cloneAgentFilesState]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAgentFiles();
|
||||
}, [loadAgentFiles]);
|
||||
|
||||
return {
|
||||
agentFiles,
|
||||
agentFilesLoading,
|
||||
agentFilesSaving,
|
||||
agentFilesDirty,
|
||||
agentFilesError,
|
||||
setAgentFileContent,
|
||||
saveAgentFiles,
|
||||
discardAgentFileChanges,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import { buildAgentMainSessionKey, isSameSessionKey } from "@/lib/gateway/GatewayClient";
|
||||
import { type GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||
import { type StudioSettings, type StudioSettingsPublic } from "@/lib/studio/settings";
|
||||
import {
|
||||
type SummaryPreviewSnapshot,
|
||||
type SummarySnapshotPatch,
|
||||
type SummaryStatusSnapshot,
|
||||
} from "@/features/agents/state/runtimeEventBridge";
|
||||
import type { AgentStoreSeed } from "@/features/agents/state/store";
|
||||
import { deriveHydrateAgentFleetResult } from "@/features/agents/operations/agentFleetHydrationDerivation";
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
getLastHello?: () => { snapshot?: unknown } | null;
|
||||
};
|
||||
|
||||
type AgentsListResult = {
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope?: string;
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
identity?: {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
type SessionsListEntry = {
|
||||
key: string;
|
||||
updatedAt?: number | null;
|
||||
displayName?: string;
|
||||
origin?: { label?: string | null; provider?: string | null } | null;
|
||||
thinkingLevel?: string;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
execHost?: string | null;
|
||||
execSecurity?: string | null;
|
||||
execAsk?: string | null;
|
||||
};
|
||||
|
||||
type SessionsListResult = {
|
||||
sessions?: SessionsListEntry[];
|
||||
};
|
||||
|
||||
type ExecApprovalsSnapshot = {
|
||||
file?: {
|
||||
agents?: Record<string, { security?: string | null; ask?: string | null }>;
|
||||
};
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const resolveAgentsListFromHelloSnapshot = (snapshot: unknown): AgentsListResult | null => {
|
||||
if (!isRecord(snapshot)) return null;
|
||||
const health = isRecord(snapshot.health) ? snapshot.health : null;
|
||||
const sessionDefaults = isRecord(snapshot.sessionDefaults) ? snapshot.sessionDefaults : null;
|
||||
const rawAgents = Array.isArray(health?.agents) ? health.agents : [];
|
||||
const agents = rawAgents.flatMap((entry) => {
|
||||
if (!isRecord(entry)) return [];
|
||||
const id = typeof entry.agentId === "string" ? entry.agentId.trim() : "";
|
||||
if (!id) return [];
|
||||
const name = typeof entry.name === "string" ? entry.name.trim() : "";
|
||||
return [
|
||||
{
|
||||
id,
|
||||
...(name ? { name } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (agents.length === 0) return null;
|
||||
const defaultId =
|
||||
typeof health?.defaultAgentId === "string"
|
||||
? health.defaultAgentId.trim()
|
||||
: agents.find((entry, index) => {
|
||||
const raw = rawAgents[index];
|
||||
return isRecord(raw) && raw.isDefault === true;
|
||||
})?.id ?? agents[0]?.id ?? "main";
|
||||
const mainKey =
|
||||
typeof sessionDefaults?.mainKey === "string" ? sessionDefaults.mainKey.trim() || "main" : "main";
|
||||
const scope =
|
||||
typeof sessionDefaults?.scope === "string" ? sessionDefaults.scope.trim() || undefined : undefined;
|
||||
return {
|
||||
defaultId,
|
||||
mainKey,
|
||||
...(scope ? { scope } : {}),
|
||||
agents,
|
||||
};
|
||||
};
|
||||
|
||||
export type HydrateAgentFleetResult = {
|
||||
seeds: AgentStoreSeed[];
|
||||
sessionCreatedAgentIds: string[];
|
||||
sessionSettingsSyncedAgentIds: string[];
|
||||
summaryPatches: SummarySnapshotPatch[];
|
||||
suggestedSelectedAgentId: string | null;
|
||||
configSnapshot: GatewayModelPolicySnapshot | null;
|
||||
};
|
||||
|
||||
export async function hydrateAgentFleetFromGateway(params: {
|
||||
client: GatewayClientLike;
|
||||
gatewayUrl: string;
|
||||
cachedConfigSnapshot: GatewayModelPolicySnapshot | null;
|
||||
loadStudioSettings: () => Promise<StudioSettings | StudioSettingsPublic | null>;
|
||||
isDisconnectLikeError: (err: unknown) => boolean;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
}): Promise<HydrateAgentFleetResult> {
|
||||
const logError = params.logError ?? ((message, error) => console.error(message, error));
|
||||
|
||||
let configSnapshot = params.cachedConfigSnapshot;
|
||||
if (!configSnapshot) {
|
||||
try {
|
||||
configSnapshot = (await params.client.call(
|
||||
"config.get",
|
||||
{}
|
||||
)) as GatewayModelPolicySnapshot;
|
||||
} catch (err) {
|
||||
if (!params.isDisconnectLikeError(err)) {
|
||||
logError("Failed to load gateway config while loading agents.", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gatewayKey = params.gatewayUrl.trim();
|
||||
let settings: StudioSettings | StudioSettingsPublic | null = null;
|
||||
if (gatewayKey) {
|
||||
try {
|
||||
settings = await params.loadStudioSettings();
|
||||
} catch (err) {
|
||||
logError("Failed to load studio settings while loading agents.", err);
|
||||
}
|
||||
}
|
||||
|
||||
let execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
|
||||
try {
|
||||
execApprovalsSnapshot = (await params.client.call(
|
||||
"exec.approvals.get",
|
||||
{}
|
||||
)) as ExecApprovalsSnapshot;
|
||||
} catch (err) {
|
||||
if (!params.isDisconnectLikeError(err)) {
|
||||
logError("Failed to load exec approvals while loading agents.", err);
|
||||
}
|
||||
}
|
||||
|
||||
let agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult;
|
||||
if (!Array.isArray(agentsResult?.agents) || agentsResult.agents.length === 0) {
|
||||
const fallback = resolveAgentsListFromHelloSnapshot(params.client.getLastHello?.()?.snapshot);
|
||||
if (fallback) {
|
||||
agentsResult = fallback;
|
||||
}
|
||||
}
|
||||
const mainKey = agentsResult.mainKey?.trim() || "main";
|
||||
|
||||
const mainSessionKeyByAgent = new Map<string, SessionsListEntry | null>();
|
||||
await Promise.all(
|
||||
agentsResult.agents.map(async (agent) => {
|
||||
try {
|
||||
const expectedMainKey = buildAgentMainSessionKey(agent.id, mainKey);
|
||||
const sessions = (await params.client.call("sessions.list", {
|
||||
agentId: agent.id,
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
search: expectedMainKey,
|
||||
limit: 4,
|
||||
})) as SessionsListResult;
|
||||
const entries = Array.isArray(sessions.sessions) ? sessions.sessions : [];
|
||||
const mainEntry =
|
||||
entries.find((entry) => isSameSessionKey(entry.key ?? "", expectedMainKey)) ?? null;
|
||||
mainSessionKeyByAgent.set(agent.id, mainEntry);
|
||||
} catch (err) {
|
||||
if (!params.isDisconnectLikeError(err)) {
|
||||
logError("Failed to list sessions while resolving agent session.", err);
|
||||
}
|
||||
mainSessionKeyByAgent.set(agent.id, null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let statusSummary: SummaryStatusSnapshot | null = null;
|
||||
let previewResult: SummaryPreviewSnapshot | null = null;
|
||||
try {
|
||||
const sessionKeys = Array.from(
|
||||
new Set(
|
||||
agentsResult.agents
|
||||
.filter((agent) => Boolean(mainSessionKeyByAgent.get(agent.id)))
|
||||
.map((agent) => buildAgentMainSessionKey(agent.id, mainKey))
|
||||
.filter((key) => key.trim().length > 0)
|
||||
)
|
||||
).slice(0, 64);
|
||||
if (sessionKeys.length > 0) {
|
||||
const snapshot = await Promise.all([
|
||||
params.client.call("status", {}) as Promise<SummaryStatusSnapshot>,
|
||||
params.client.call("sessions.preview", {
|
||||
keys: sessionKeys,
|
||||
limit: 8,
|
||||
maxChars: 240,
|
||||
}) as Promise<SummaryPreviewSnapshot>,
|
||||
]);
|
||||
statusSummary = snapshot[0] ?? null;
|
||||
previewResult = snapshot[1] ?? null;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!params.isDisconnectLikeError(err)) {
|
||||
logError("Failed to load initial summary snapshot.", err);
|
||||
}
|
||||
}
|
||||
|
||||
const derived = deriveHydrateAgentFleetResult({
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
configSnapshot: configSnapshot ?? null,
|
||||
settings,
|
||||
execApprovalsSnapshot,
|
||||
agentsResult,
|
||||
mainSessionByAgentId: mainSessionKeyByAgent,
|
||||
statusSummary,
|
||||
previewResult,
|
||||
});
|
||||
|
||||
return derived;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { buildAgentMainSessionKey } from "@/lib/gateway/GatewayClient";
|
||||
import { resolveConfiguredModelKey, type GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||
import {
|
||||
resolveAgentAvatarSeed,
|
||||
type StudioSettings,
|
||||
type StudioSettingsPublic,
|
||||
} from "@/lib/studio/settings";
|
||||
import {
|
||||
buildSummarySnapshotPatches,
|
||||
type SummaryPreviewSnapshot,
|
||||
type SummarySnapshotAgent,
|
||||
type SummarySnapshotPatch,
|
||||
type SummaryStatusSnapshot,
|
||||
} from "@/features/agents/state/runtimeEventBridge";
|
||||
import type { AgentStoreSeed } from "@/features/agents/state/store";
|
||||
|
||||
type AgentsListResult = {
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope?: string;
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
identity?: {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
type SessionsListEntry = {
|
||||
key: string;
|
||||
updatedAt?: number | null;
|
||||
displayName?: string;
|
||||
origin?: { label?: string | null; provider?: string | null } | null;
|
||||
thinkingLevel?: string;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
execHost?: string | null;
|
||||
execSecurity?: string | null;
|
||||
execAsk?: string | null;
|
||||
};
|
||||
|
||||
type ExecHost = "sandbox" | "gateway" | "node";
|
||||
type ExecSecurity = "deny" | "allowlist" | "full";
|
||||
type ExecAsk = "off" | "on-miss" | "always";
|
||||
|
||||
type ExecApprovalsSnapshot = {
|
||||
file?: {
|
||||
agents?: Record<string, { security?: string | null; ask?: string | null }>;
|
||||
};
|
||||
};
|
||||
|
||||
type ExecPolicyEntry = {
|
||||
security?: ExecSecurity;
|
||||
ask?: ExecAsk;
|
||||
};
|
||||
|
||||
type SandboxMode = "off" | "non-main" | "all";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const resolveAgentSandboxMode = (
|
||||
agentId: string,
|
||||
snapshot: GatewayModelPolicySnapshot | null
|
||||
): SandboxMode | null => {
|
||||
const resolvedAgentId = agentId.trim();
|
||||
if (!resolvedAgentId) return null;
|
||||
const configRaw = snapshot?.config as unknown;
|
||||
if (!isRecord(configRaw)) return null;
|
||||
const agents = isRecord(configRaw.agents) ? configRaw.agents : null;
|
||||
const list = Array.isArray(agents?.list) ? agents?.list : [];
|
||||
for (const entryRaw of list) {
|
||||
if (!isRecord(entryRaw)) continue;
|
||||
const id = typeof entryRaw.id === "string" ? entryRaw.id.trim() : "";
|
||||
if (!id || id !== resolvedAgentId) continue;
|
||||
const sandbox = isRecord(entryRaw.sandbox) ? entryRaw.sandbox : null;
|
||||
const modeRaw = typeof sandbox?.mode === "string" ? sandbox.mode.trim().toLowerCase() : "";
|
||||
if (modeRaw === "off" || modeRaw === "non-main" || modeRaw === "all") {
|
||||
return modeRaw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeExecHost = (raw: string | null | undefined): ExecHost | undefined => {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeExecSecurity = (raw: string | null | undefined): ExecSecurity | undefined => {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeExecAsk = (raw: string | null | undefined): ExecAsk | undefined => {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolveAgentName = (agent: AgentsListResult["agents"][number]) => {
|
||||
const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||
if (fromList) return fromList;
|
||||
const fromIdentity = typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
|
||||
if (fromIdentity) return fromIdentity;
|
||||
return agent.id;
|
||||
};
|
||||
|
||||
const resolveAgentAvatarUrl = (agent: AgentsListResult["agents"][number]) => {
|
||||
const candidate = agent.identity?.avatarUrl ?? agent.identity?.avatar ?? null;
|
||||
if (typeof candidate !== "string") return null;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed;
|
||||
if (trimmed.startsWith("data:image/")) return trimmed;
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveDefaultModelForAgent = (
|
||||
agentId: string,
|
||||
snapshot: GatewayModelPolicySnapshot | null
|
||||
): string | null => {
|
||||
const resolvedAgentId = agentId.trim();
|
||||
if (!resolvedAgentId) return null;
|
||||
const defaults = snapshot?.config?.agents?.defaults;
|
||||
const modelAliases = defaults?.models;
|
||||
const agentEntry =
|
||||
snapshot?.config?.agents?.list?.find((entry) => entry?.id?.trim() === resolvedAgentId) ??
|
||||
null;
|
||||
const agentModel = agentEntry?.model;
|
||||
let raw: string | null = null;
|
||||
if (typeof agentModel === "string") {
|
||||
raw = agentModel;
|
||||
} else if (agentModel && typeof agentModel === "object") {
|
||||
raw = agentModel.primary ?? null;
|
||||
}
|
||||
if (!raw) {
|
||||
const defaultModel = defaults?.model;
|
||||
if (typeof defaultModel === "string") {
|
||||
raw = defaultModel;
|
||||
} else if (defaultModel && typeof defaultModel === "object") {
|
||||
raw = defaultModel.primary ?? null;
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
return resolveConfiguredModelKey(raw, modelAliases);
|
||||
};
|
||||
|
||||
export type DeriveFleetHydrationInput = {
|
||||
gatewayUrl: string;
|
||||
configSnapshot: GatewayModelPolicySnapshot | null;
|
||||
settings: StudioSettings | StudioSettingsPublic | null;
|
||||
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
|
||||
agentsResult: AgentsListResult;
|
||||
mainSessionByAgentId: Map<string, SessionsListEntry | null>;
|
||||
statusSummary: SummaryStatusSnapshot | null;
|
||||
previewResult: SummaryPreviewSnapshot | null;
|
||||
};
|
||||
|
||||
export type DerivedHydrateAgentFleetResult = {
|
||||
seeds: AgentStoreSeed[];
|
||||
sessionCreatedAgentIds: string[];
|
||||
sessionSettingsSyncedAgentIds: string[];
|
||||
summaryPatches: SummarySnapshotPatch[];
|
||||
suggestedSelectedAgentId: string | null;
|
||||
configSnapshot: GatewayModelPolicySnapshot | null;
|
||||
};
|
||||
|
||||
export const deriveHydrateAgentFleetResult = (
|
||||
input: DeriveFleetHydrationInput
|
||||
): DerivedHydrateAgentFleetResult => {
|
||||
const execPolicyByAgentId = new Map<string, ExecPolicyEntry>();
|
||||
const execAgents = input.execApprovalsSnapshot?.file?.agents ?? {};
|
||||
for (const [agentId, entry] of Object.entries(execAgents)) {
|
||||
const normalizedSecurity = normalizeExecSecurity(entry?.security);
|
||||
const normalizedAsk = normalizeExecAsk(entry?.ask);
|
||||
if (!normalizedSecurity && !normalizedAsk) continue;
|
||||
execPolicyByAgentId.set(agentId, {
|
||||
security: normalizedSecurity,
|
||||
ask: normalizedAsk,
|
||||
});
|
||||
}
|
||||
|
||||
const mainKey = input.agentsResult.mainKey?.trim() || "main";
|
||||
const gatewayKey = input.gatewayUrl.trim();
|
||||
|
||||
const needsSessionSettingsSync = new Set<string>();
|
||||
const seeds: AgentStoreSeed[] = input.agentsResult.agents.map((agent) => {
|
||||
const persistedSeed =
|
||||
input.settings && gatewayKey ? resolveAgentAvatarSeed(input.settings, gatewayKey, agent.id) : null;
|
||||
const avatarSeed = persistedSeed ?? agent.id;
|
||||
const avatarUrl = resolveAgentAvatarUrl(agent);
|
||||
const name = resolveAgentName(agent);
|
||||
const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null;
|
||||
const modelProvider =
|
||||
typeof mainSession?.modelProvider === "string" ? mainSession.modelProvider.trim() : "";
|
||||
const modelId = typeof mainSession?.model === "string" ? mainSession.model.trim() : "";
|
||||
const model =
|
||||
modelProvider && modelId
|
||||
? `${modelProvider}/${modelId}`
|
||||
: resolveDefaultModelForAgent(agent.id, input.configSnapshot);
|
||||
const thinkingLevel =
|
||||
typeof mainSession?.thinkingLevel === "string" ? mainSession.thinkingLevel : null;
|
||||
const sessionExecHost = normalizeExecHost(mainSession?.execHost);
|
||||
const sessionExecSecurity = normalizeExecSecurity(mainSession?.execSecurity);
|
||||
const sessionExecAsk = normalizeExecAsk(mainSession?.execAsk);
|
||||
const policy = execPolicyByAgentId.get(agent.id);
|
||||
const sandboxMode = resolveAgentSandboxMode(agent.id, input.configSnapshot);
|
||||
const resolvedExecSecurity = sessionExecSecurity ?? policy?.security;
|
||||
const resolvedExecAsk = sessionExecAsk ?? policy?.ask;
|
||||
const shouldForceSandboxExecHost =
|
||||
sandboxMode === "all" &&
|
||||
Boolean(sessionExecHost || resolvedExecSecurity || resolvedExecAsk);
|
||||
const resolvedExecHost = shouldForceSandboxExecHost
|
||||
? "sandbox"
|
||||
: sessionExecHost ??
|
||||
(resolvedExecSecurity || resolvedExecAsk ? "gateway" : undefined);
|
||||
const expectsExecOverrides = Boolean(
|
||||
resolvedExecHost || resolvedExecSecurity || resolvedExecAsk
|
||||
);
|
||||
const hasMatchingExecOverrides =
|
||||
sessionExecHost === resolvedExecHost &&
|
||||
sessionExecSecurity === resolvedExecSecurity &&
|
||||
sessionExecAsk === resolvedExecAsk;
|
||||
if (expectsExecOverrides && !hasMatchingExecOverrides) {
|
||||
needsSessionSettingsSync.add(agent.id);
|
||||
}
|
||||
return {
|
||||
agentId: agent.id,
|
||||
name,
|
||||
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
|
||||
avatarSeed,
|
||||
avatarUrl,
|
||||
model,
|
||||
thinkingLevel,
|
||||
sessionExecHost: resolvedExecHost,
|
||||
sessionExecSecurity: resolvedExecSecurity,
|
||||
sessionExecAsk: resolvedExecAsk,
|
||||
};
|
||||
});
|
||||
|
||||
const sessionCreatedAgentIds: string[] = [];
|
||||
const sessionSettingsSyncedAgentIds: string[] = [];
|
||||
for (const seed of seeds) {
|
||||
const mainSession = input.mainSessionByAgentId.get(seed.agentId) ?? null;
|
||||
if (!mainSession) continue;
|
||||
sessionCreatedAgentIds.push(seed.agentId);
|
||||
if (!needsSessionSettingsSync.has(seed.agentId)) {
|
||||
sessionSettingsSyncedAgentIds.push(seed.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
let summaryPatches: SummarySnapshotPatch[] = [];
|
||||
let suggestedSelectedAgentId: string | null = null;
|
||||
if (input.statusSummary && input.previewResult) {
|
||||
const activeAgents: SummarySnapshotAgent[] = [];
|
||||
for (const seed of seeds) {
|
||||
const mainSession = input.mainSessionByAgentId.get(seed.agentId) ?? null;
|
||||
if (!mainSession) continue;
|
||||
activeAgents.push({
|
||||
agentId: seed.agentId,
|
||||
sessionKey: seed.sessionKey,
|
||||
status: "idle",
|
||||
});
|
||||
}
|
||||
const sessionKeys = Array.from(
|
||||
new Set(activeAgents.map((agent) => agent.sessionKey).filter((key) => key.trim().length > 0))
|
||||
).slice(0, 64);
|
||||
if (sessionKeys.length > 0) {
|
||||
summaryPatches = buildSummarySnapshotPatches({
|
||||
agents: activeAgents,
|
||||
statusSummary: input.statusSummary,
|
||||
previewResult: input.previewResult,
|
||||
});
|
||||
|
||||
const assistantAtByAgentId = new Map<string, number>();
|
||||
for (const entry of summaryPatches) {
|
||||
if (typeof entry.patch.lastAssistantMessageAt === "number") {
|
||||
assistantAtByAgentId.set(entry.agentId, entry.patch.lastAssistantMessageAt);
|
||||
}
|
||||
}
|
||||
|
||||
let bestAgentId: string | null = seeds[0]?.agentId ?? null;
|
||||
let bestTs = bestAgentId ? assistantAtByAgentId.get(bestAgentId) ?? 0 : 0;
|
||||
for (const seed of seeds) {
|
||||
const ts = assistantAtByAgentId.get(seed.agentId) ?? 0;
|
||||
if (ts <= bestTs) continue;
|
||||
bestTs = ts;
|
||||
bestAgentId = seed.agentId;
|
||||
}
|
||||
suggestedSelectedAgentId = bestAgentId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
seeds,
|
||||
sessionCreatedAgentIds,
|
||||
sessionSettingsSyncedAgentIds,
|
||||
summaryPatches,
|
||||
suggestedSelectedAgentId,
|
||||
configSnapshot: input.configSnapshot ?? null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
readGatewayAgentExecApprovals,
|
||||
upsertGatewayAgentExecApprovals,
|
||||
} from "@/lib/gateway/execApprovals";
|
||||
import { readConfigAgentList, updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig";
|
||||
|
||||
export type ExecutionRoleId = "conservative" | "collaborative" | "autonomous";
|
||||
export type CommandModeId = "off" | "ask" | "auto";
|
||||
|
||||
export type AgentPermissionsDraft = {
|
||||
commandMode: CommandModeId;
|
||||
webAccess: boolean;
|
||||
fileTools: boolean;
|
||||
};
|
||||
|
||||
export type ToolGroupState = {
|
||||
runtime: boolean | null;
|
||||
web: boolean | null;
|
||||
fs: boolean | null;
|
||||
usesAllow: boolean;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const coerceStringArray = (value: unknown): string[] | null => {
|
||||
if (!Array.isArray(value)) return null;
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
};
|
||||
|
||||
export const resolveExecutionRoleFromAgent = (agent: {
|
||||
sessionExecSecurity?: "deny" | "allowlist" | "full";
|
||||
sessionExecAsk?: "off" | "on-miss" | "always";
|
||||
}): ExecutionRoleId => {
|
||||
if (agent.sessionExecSecurity === "full" && agent.sessionExecAsk === "off") {
|
||||
return "autonomous";
|
||||
}
|
||||
if (
|
||||
agent.sessionExecSecurity === "allowlist" ||
|
||||
agent.sessionExecAsk === "always" ||
|
||||
agent.sessionExecAsk === "on-miss"
|
||||
) {
|
||||
return "collaborative";
|
||||
}
|
||||
return "conservative";
|
||||
};
|
||||
|
||||
export const resolveRoleForCommandMode = (mode: CommandModeId): ExecutionRoleId => {
|
||||
if (mode === "auto") return "autonomous";
|
||||
if (mode === "ask") return "collaborative";
|
||||
return "conservative";
|
||||
};
|
||||
|
||||
export const resolveCommandModeFromRole = (role: ExecutionRoleId): CommandModeId => {
|
||||
if (role === "autonomous") return "auto";
|
||||
if (role === "collaborative") return "ask";
|
||||
return "off";
|
||||
};
|
||||
|
||||
export const resolvePresetDefaultsForRole = (role: ExecutionRoleId): AgentPermissionsDraft => {
|
||||
const commandMode = resolveCommandModeFromRole(role);
|
||||
if (role === "conservative") {
|
||||
return {
|
||||
commandMode,
|
||||
webAccess: false,
|
||||
fileTools: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
commandMode,
|
||||
webAccess: true,
|
||||
fileTools: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveEffectivePermissionsSummary = (draft: AgentPermissionsDraft): string => {
|
||||
const commandLabel =
|
||||
draft.commandMode === "auto"
|
||||
? "Commands: Auto"
|
||||
: draft.commandMode === "ask"
|
||||
? "Commands: Ask"
|
||||
: "Commands: Off";
|
||||
const webLabel = draft.webAccess ? "Web: On" : "Web: Off";
|
||||
const fileLabel = draft.fileTools ? "File tools: On" : "File tools: Off";
|
||||
return `${commandLabel} | ${webLabel} | ${fileLabel}`;
|
||||
};
|
||||
|
||||
export const isPermissionsCustom = (params: {
|
||||
role: ExecutionRoleId;
|
||||
draft: AgentPermissionsDraft;
|
||||
}): boolean => {
|
||||
const defaults = resolvePresetDefaultsForRole(params.role);
|
||||
return (
|
||||
defaults.commandMode !== params.draft.commandMode ||
|
||||
defaults.webAccess !== params.draft.webAccess ||
|
||||
defaults.fileTools !== params.draft.fileTools
|
||||
);
|
||||
};
|
||||
|
||||
const resolveGroupState = (params: {
|
||||
group: "group:runtime" | "group:web" | "group:fs";
|
||||
allowed: Set<string>;
|
||||
denied: Set<string>;
|
||||
}): boolean | null => {
|
||||
if (params.denied.has(params.group)) return false;
|
||||
if (params.allowed.has(params.group)) return true;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveToolGroupStateFromConfigEntry = (existingTools: unknown): ToolGroupState => {
|
||||
const tools = isRecord(existingTools) ? existingTools : null;
|
||||
const existingAllow = coerceStringArray(tools?.allow);
|
||||
const existingAlsoAllow = coerceStringArray(tools?.alsoAllow);
|
||||
const existingDeny = coerceStringArray(tools?.deny) ?? [];
|
||||
const usesAllow = existingAllow !== null;
|
||||
const allowed = new Set(usesAllow ? existingAllow : existingAlsoAllow ?? []);
|
||||
const denied = new Set(existingDeny);
|
||||
|
||||
return {
|
||||
runtime: resolveGroupState({ group: "group:runtime", allowed, denied }),
|
||||
web: resolveGroupState({ group: "group:web", allowed, denied }),
|
||||
fs: resolveGroupState({ group: "group:fs", allowed, denied }),
|
||||
usesAllow,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveAgentPermissionsDraft = (params: {
|
||||
agent: {
|
||||
sessionExecSecurity?: "deny" | "allowlist" | "full";
|
||||
sessionExecAsk?: "off" | "on-miss" | "always";
|
||||
};
|
||||
existingTools: unknown;
|
||||
}): AgentPermissionsDraft => {
|
||||
const role = resolveExecutionRoleFromAgent(params.agent);
|
||||
const defaults = resolvePresetDefaultsForRole(role);
|
||||
const groupState = resolveToolGroupStateFromConfigEntry(params.existingTools);
|
||||
|
||||
return {
|
||||
commandMode: defaults.commandMode,
|
||||
webAccess: groupState.web ?? defaults.webAccess,
|
||||
fileTools: groupState.fs ?? defaults.fileTools,
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveExecApprovalsPolicyForRole(params: {
|
||||
role: ExecutionRoleId;
|
||||
allowlist: Array<{ pattern: string }>;
|
||||
}):
|
||||
| {
|
||||
security: "full" | "allowlist";
|
||||
ask: "off" | "always";
|
||||
allowlist: Array<{ pattern: string }>;
|
||||
}
|
||||
| null {
|
||||
if (params.role === "conservative") return null;
|
||||
if (params.role === "autonomous") {
|
||||
return { security: "full", ask: "off", allowlist: params.allowlist };
|
||||
}
|
||||
return { security: "allowlist", ask: "always", allowlist: params.allowlist };
|
||||
}
|
||||
|
||||
export function resolveToolGroupOverrides(params: {
|
||||
existingTools: unknown;
|
||||
runtimeEnabled: boolean;
|
||||
webEnabled: boolean;
|
||||
fsEnabled: boolean;
|
||||
}): { tools: { allow?: string[]; alsoAllow?: string[]; deny?: string[] } } {
|
||||
const tools = isRecord(params.existingTools) ? params.existingTools : null;
|
||||
|
||||
const existingAllow = coerceStringArray(tools?.allow);
|
||||
const existingAlsoAllow = coerceStringArray(tools?.alsoAllow);
|
||||
const existingDeny = coerceStringArray(tools?.deny) ?? [];
|
||||
|
||||
const usesAllow = existingAllow !== null;
|
||||
const allowed = new Set(usesAllow ? existingAllow : existingAlsoAllow ?? []);
|
||||
const denied = new Set(existingDeny);
|
||||
|
||||
const applyGroup = (group: "group:runtime" | "group:web" | "group:fs", enabled: boolean) => {
|
||||
if (enabled) {
|
||||
allowed.add(group);
|
||||
denied.delete(group);
|
||||
return;
|
||||
}
|
||||
allowed.delete(group);
|
||||
denied.add(group);
|
||||
};
|
||||
|
||||
applyGroup("group:runtime", params.runtimeEnabled);
|
||||
applyGroup("group:web", params.webEnabled);
|
||||
applyGroup("group:fs", params.fsEnabled);
|
||||
|
||||
const allowedList = Array.from(allowed);
|
||||
const denyList = Array.from(denied).filter((entry) => !allowed.has(entry));
|
||||
|
||||
return {
|
||||
tools: usesAllow
|
||||
? { allow: allowedList, deny: denyList }
|
||||
: { alsoAllow: allowedList, deny: denyList },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSessionExecSettingsForRole(params: {
|
||||
role: ExecutionRoleId;
|
||||
sandboxMode: string;
|
||||
}): {
|
||||
execHost: "sandbox" | "gateway" | null;
|
||||
execSecurity: "deny" | "allowlist" | "full";
|
||||
execAsk: "off" | "always";
|
||||
} {
|
||||
if (params.role === "conservative") {
|
||||
return { execHost: null, execSecurity: "deny", execAsk: "off" };
|
||||
}
|
||||
|
||||
const normalizedMode = params.sandboxMode.trim().toLowerCase();
|
||||
const execHost = normalizedMode === "all" ? "sandbox" : "gateway";
|
||||
if (params.role === "autonomous") {
|
||||
return { execHost, execSecurity: "full", execAsk: "off" };
|
||||
}
|
||||
return { execHost, execSecurity: "allowlist", execAsk: "always" };
|
||||
}
|
||||
|
||||
export function resolveRuntimeToolOverridesForRole(params: {
|
||||
role: ExecutionRoleId;
|
||||
existingTools: unknown;
|
||||
}): { tools: { allow?: string[]; alsoAllow?: string[]; deny?: string[] } } {
|
||||
const tools = isRecord(params.existingTools) ? params.existingTools : null;
|
||||
|
||||
const existingAllow = coerceStringArray(tools?.allow);
|
||||
const existingAlsoAllow = coerceStringArray(tools?.alsoAllow);
|
||||
const existingDeny = coerceStringArray(tools?.deny) ?? [];
|
||||
|
||||
const usesAllow = existingAllow !== null;
|
||||
const baseAllowed = new Set(usesAllow ? existingAllow : existingAlsoAllow ?? []);
|
||||
const deny = new Set(existingDeny);
|
||||
|
||||
if (params.role === "conservative") {
|
||||
baseAllowed.delete("group:runtime");
|
||||
deny.add("group:runtime");
|
||||
} else {
|
||||
baseAllowed.add("group:runtime");
|
||||
deny.delete("group:runtime");
|
||||
}
|
||||
|
||||
const allowedList = Array.from(baseAllowed);
|
||||
const denyList = Array.from(deny).filter((entry) => !baseAllowed.has(entry));
|
||||
|
||||
return {
|
||||
tools: usesAllow
|
||||
? { allow: allowedList, deny: denyList }
|
||||
: { alsoAllow: allowedList, deny: denyList },
|
||||
};
|
||||
}
|
||||
|
||||
type AgentRuntimeConfigContext = {
|
||||
sandboxMode: string;
|
||||
tools: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const resolveAgentRuntimeConfigContext = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
}): Promise<AgentRuntimeConfigContext> => {
|
||||
const snapshot = await params.client.call<{ config?: unknown }>("config.get", {});
|
||||
const baseConfig =
|
||||
snapshot.config && typeof snapshot.config === "object" && !Array.isArray(snapshot.config)
|
||||
? (snapshot.config as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const list = readConfigAgentList(baseConfig);
|
||||
const configEntry = list.find((entry) => entry.id === params.agentId) ?? null;
|
||||
|
||||
const sandboxRaw =
|
||||
configEntry && typeof (configEntry as Record<string, unknown>).sandbox === "object"
|
||||
? ((configEntry as Record<string, unknown>).sandbox as unknown)
|
||||
: null;
|
||||
const sandbox =
|
||||
sandboxRaw && typeof sandboxRaw === "object" && !Array.isArray(sandboxRaw)
|
||||
? (sandboxRaw as Record<string, unknown>)
|
||||
: null;
|
||||
const sandboxMode = typeof sandbox?.mode === "string" ? sandbox.mode.trim().toLowerCase() : "";
|
||||
|
||||
const toolsRaw =
|
||||
configEntry && typeof (configEntry as Record<string, unknown>).tools === "object"
|
||||
? ((configEntry as Record<string, unknown>).tools as unknown)
|
||||
: null;
|
||||
const tools =
|
||||
toolsRaw && typeof toolsRaw === "object" && !Array.isArray(toolsRaw)
|
||||
? (toolsRaw as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return {
|
||||
sandboxMode,
|
||||
tools,
|
||||
};
|
||||
};
|
||||
|
||||
const upsertExecApprovalsPolicyForRole = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
role: ExecutionRoleId;
|
||||
}) => {
|
||||
const existingPolicy = await readGatewayAgentExecApprovals({
|
||||
client: params.client,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const allowlist = existingPolicy?.allowlist ?? [];
|
||||
const nextPolicy = resolveExecApprovalsPolicyForRole({ role: params.role, allowlist });
|
||||
|
||||
await upsertGatewayAgentExecApprovals({
|
||||
client: params.client,
|
||||
agentId: params.agentId,
|
||||
policy: nextPolicy,
|
||||
});
|
||||
};
|
||||
|
||||
export async function updateAgentPermissionsViaStudio(params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
draft: AgentPermissionsDraft;
|
||||
loadAgents?: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const agentId = params.agentId.trim();
|
||||
if (!agentId) {
|
||||
throw new Error("Agent id is required.");
|
||||
}
|
||||
|
||||
const role = resolveRoleForCommandMode(params.draft.commandMode);
|
||||
await upsertExecApprovalsPolicyForRole({
|
||||
client: params.client,
|
||||
agentId,
|
||||
role,
|
||||
});
|
||||
const runtimeConfigContext = await resolveAgentRuntimeConfigContext({
|
||||
client: params.client,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const toolOverrides = resolveToolGroupOverrides({
|
||||
existingTools: runtimeConfigContext.tools,
|
||||
runtimeEnabled: role !== "conservative",
|
||||
webEnabled: params.draft.webAccess,
|
||||
fsEnabled: params.draft.fileTools,
|
||||
});
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client: params.client,
|
||||
agentId,
|
||||
overrides: toolOverrides,
|
||||
});
|
||||
|
||||
const execSettings = resolveSessionExecSettingsForRole({
|
||||
role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
await syncGatewaySessionSettings({
|
||||
client: params.client,
|
||||
sessionKey: params.sessionKey,
|
||||
execHost: execSettings.execHost,
|
||||
execSecurity: execSettings.execSecurity,
|
||||
execAsk: execSettings.execAsk,
|
||||
});
|
||||
|
||||
if (params.loadAgents) {
|
||||
await params.loadAgents();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateExecutionRoleViaStudio(params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
role: ExecutionRoleId;
|
||||
loadAgents: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const agentId = params.agentId.trim();
|
||||
if (!agentId) {
|
||||
throw new Error("Agent id is required.");
|
||||
}
|
||||
|
||||
await upsertExecApprovalsPolicyForRole({
|
||||
client: params.client,
|
||||
agentId,
|
||||
role: params.role,
|
||||
});
|
||||
const runtimeConfigContext = await resolveAgentRuntimeConfigContext({
|
||||
client: params.client,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const toolOverrides = resolveRuntimeToolOverridesForRole({
|
||||
role: params.role,
|
||||
existingTools: runtimeConfigContext.tools,
|
||||
});
|
||||
await updateGatewayAgentOverrides({
|
||||
client: params.client,
|
||||
agentId,
|
||||
overrides: toolOverrides,
|
||||
});
|
||||
|
||||
const execSettings = resolveSessionExecSettingsForRole({
|
||||
role: params.role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
await syncGatewaySessionSettings({
|
||||
client: params.client,
|
||||
sessionKey: params.sessionKey,
|
||||
execHost: execSettings.execHost,
|
||||
execSecurity: execSettings.execSecurity,
|
||||
execAsk: execSettings.execAsk,
|
||||
});
|
||||
|
||||
await params.loadAgents();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import {
|
||||
buildReconcileTerminalPatch,
|
||||
resolveReconcileEligibility,
|
||||
resolveReconcileWaitOutcome,
|
||||
} from "@/features/agents/operations/fleetLifecycleWorkflow";
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type ReconcileCommand =
|
||||
| { kind: "clearRunTracking"; runId: string }
|
||||
| { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
| { kind: "requestHistoryRefresh"; agentId: string }
|
||||
| { kind: "logInfo"; message: string }
|
||||
| { kind: "logWarn"; message: string; error: unknown };
|
||||
|
||||
type ReconcileDispatchAction = {
|
||||
type: "updateAgent";
|
||||
agentId: string;
|
||||
patch: Partial<AgentState>;
|
||||
};
|
||||
|
||||
export const executeAgentReconcileCommands = (params: {
|
||||
commands: ReconcileCommand[];
|
||||
dispatch: (action: ReconcileDispatchAction) => void;
|
||||
clearRunTracking: (runId: string) => void;
|
||||
requestHistoryRefresh: (agentId: string) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
}) => {
|
||||
for (const command of params.commands) {
|
||||
if (command.kind === "clearRunTracking") {
|
||||
params.clearRunTracking(command.runId);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "dispatchUpdateAgent") {
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId: command.agentId,
|
||||
patch: command.patch,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "requestHistoryRefresh") {
|
||||
params.requestHistoryRefresh(command.agentId);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "logInfo") {
|
||||
params.logInfo(command.message);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "logWarn") {
|
||||
params.logWarn(command.message, command.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const runAgentReconcileOperation = async (params: {
|
||||
client: GatewayClientLike;
|
||||
agents: AgentState[];
|
||||
getLatestAgent: (agentId: string) => AgentState | null;
|
||||
claimRunId: (runId: string) => boolean;
|
||||
releaseRunId: (runId: string) => void;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
}): Promise<ReconcileCommand[]> => {
|
||||
const commands: ReconcileCommand[] = [];
|
||||
|
||||
for (const agent of params.agents) {
|
||||
const eligibility = resolveReconcileEligibility({
|
||||
status: agent.status,
|
||||
sessionCreated: agent.sessionCreated,
|
||||
runId: agent.runId,
|
||||
});
|
||||
if (!eligibility.shouldCheck) continue;
|
||||
|
||||
const runId = agent.runId?.trim() ?? "";
|
||||
if (!runId) continue;
|
||||
|
||||
if (!params.claimRunId(runId)) continue;
|
||||
|
||||
try {
|
||||
const result = (await params.client.call("agent.wait", {
|
||||
runId,
|
||||
timeoutMs: 1,
|
||||
})) as { status?: unknown };
|
||||
const outcome = resolveReconcileWaitOutcome(result?.status);
|
||||
if (!outcome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const latest = params.getLatestAgent(agent.agentId);
|
||||
if (!latest || latest.runId !== runId || latest.status !== "running") {
|
||||
continue;
|
||||
}
|
||||
|
||||
commands.push({ kind: "clearRunTracking", runId });
|
||||
commands.push({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: agent.agentId,
|
||||
patch: buildReconcileTerminalPatch({ outcome }),
|
||||
});
|
||||
commands.push({
|
||||
kind: "logInfo",
|
||||
message: `[agent-reconcile] ${agent.agentId} run ${runId} resolved as ${outcome}.`,
|
||||
});
|
||||
commands.push({
|
||||
kind: "requestHistoryRefresh",
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!params.isDisconnectLikeError(err)) {
|
||||
commands.push({
|
||||
kind: "logWarn",
|
||||
message: "Failed to reconcile running agent.",
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
params.releaseRunId(runId);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
resolveMutationStartGuard,
|
||||
type MutationStartGuardResult,
|
||||
} from "@/features/agents/operations/mutationLifecycleWorkflow";
|
||||
|
||||
export const RESERVED_MAIN_AGENT_ID = "main";
|
||||
|
||||
type GuardedActionKind =
|
||||
| "delete-agent"
|
||||
| "rename-agent"
|
||||
| "update-agent-permissions"
|
||||
| "use-all-skills"
|
||||
| "disable-all-skills"
|
||||
| "set-skills-allowlist"
|
||||
| "set-skill-enabled"
|
||||
| "set-skill-global-enabled"
|
||||
| "install-skill"
|
||||
| "remove-skill"
|
||||
| "save-skill-api-key";
|
||||
type CronActionKind = "run-cron-job" | "delete-cron-job";
|
||||
|
||||
export type AgentSettingsMutationRequest =
|
||||
| { kind: GuardedActionKind; agentId: string; skillName?: string; skillKey?: string }
|
||||
| { kind: "create-cron-job"; agentId: string }
|
||||
| { kind: CronActionKind; agentId: string; jobId: string };
|
||||
|
||||
export type AgentSettingsMutationContext = {
|
||||
status: "connected" | "connecting" | "disconnected";
|
||||
hasCreateBlock: boolean;
|
||||
hasRenameBlock: boolean;
|
||||
hasDeleteBlock: boolean;
|
||||
cronCreateBusy: boolean;
|
||||
cronRunBusyJobId: string | null;
|
||||
cronDeleteBusyJobId: string | null;
|
||||
};
|
||||
|
||||
export type AgentSettingsMutationDenyReason =
|
||||
| "start-guard-deny"
|
||||
| "reserved-main-delete"
|
||||
| "cron-action-busy"
|
||||
| "missing-agent-id"
|
||||
| "missing-job-id"
|
||||
| "missing-skill-name"
|
||||
| "missing-skill-key";
|
||||
|
||||
export type AgentSettingsMutationDecision =
|
||||
| {
|
||||
kind: "allow";
|
||||
normalizedAgentId: string;
|
||||
normalizedJobId?: string;
|
||||
}
|
||||
| {
|
||||
kind: "deny";
|
||||
reason: AgentSettingsMutationDenyReason;
|
||||
message: string | null;
|
||||
guardReason?: Exclude<MutationStartGuardResult, { kind: "allow" }>["reason"];
|
||||
};
|
||||
|
||||
const normalizeId = (value: string) => value.trim();
|
||||
|
||||
const isGuardedAction = (
|
||||
kind: AgentSettingsMutationRequest["kind"]
|
||||
): kind is GuardedActionKind =>
|
||||
kind === "delete-agent" ||
|
||||
kind === "rename-agent" ||
|
||||
kind === "update-agent-permissions" ||
|
||||
kind === "use-all-skills" ||
|
||||
kind === "disable-all-skills" ||
|
||||
kind === "set-skills-allowlist" ||
|
||||
kind === "set-skill-enabled" ||
|
||||
kind === "set-skill-global-enabled" ||
|
||||
kind === "install-skill" ||
|
||||
kind === "remove-skill" ||
|
||||
kind === "save-skill-api-key";
|
||||
|
||||
const isCronActionBusy = (context: AgentSettingsMutationContext) =>
|
||||
context.cronCreateBusy ||
|
||||
Boolean(context.cronRunBusyJobId?.trim()) ||
|
||||
Boolean(context.cronDeleteBusyJobId?.trim());
|
||||
|
||||
export const planAgentSettingsMutation = (
|
||||
request: AgentSettingsMutationRequest,
|
||||
context: AgentSettingsMutationContext
|
||||
): AgentSettingsMutationDecision => {
|
||||
const normalizedAgentId = normalizeId(request.agentId);
|
||||
if (!normalizedAgentId) {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "missing-agent-id",
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isGuardedAction(request.kind)) {
|
||||
const startGuard = resolveMutationStartGuard({
|
||||
status: context.status,
|
||||
hasCreateBlock: context.hasCreateBlock,
|
||||
hasRenameBlock: context.hasRenameBlock,
|
||||
hasDeleteBlock: context.hasDeleteBlock,
|
||||
});
|
||||
if (startGuard.kind === "deny") {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: startGuard.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (request.kind === "delete-agent" && normalizedAgentId === RESERVED_MAIN_AGENT_ID) {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "reserved-main-delete",
|
||||
message: "The main agent cannot be deleted.",
|
||||
};
|
||||
}
|
||||
|
||||
if (request.kind === "run-cron-job" || request.kind === "delete-cron-job") {
|
||||
const normalizedJobId = normalizeId(request.jobId);
|
||||
if (!normalizedJobId) {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "missing-job-id",
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isCronActionBusy(context)) {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "cron-action-busy",
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "allow",
|
||||
normalizedAgentId,
|
||||
normalizedJobId,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.kind === "set-skill-enabled") {
|
||||
const normalizedSkillName = normalizeId(request.skillName ?? "");
|
||||
if (!normalizedSkillName) {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "missing-skill-name",
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
request.kind === "set-skill-global-enabled" ||
|
||||
request.kind === "install-skill" ||
|
||||
request.kind === "remove-skill" ||
|
||||
request.kind === "save-skill-api-key"
|
||||
) {
|
||||
const normalizedSkillKey = normalizeId(request.skillKey ?? "");
|
||||
if (!normalizedSkillKey) {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "missing-skill-key",
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "allow",
|
||||
normalizedAgentId,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export type StopRunIntent =
|
||||
| { kind: "deny"; reason: "not-connected" | "missing-session-key"; message: string }
|
||||
| { kind: "skip-busy" }
|
||||
| { kind: "allow"; sessionKey: string };
|
||||
|
||||
export const planStopRunIntent = (input: {
|
||||
status: GatewayStatus;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
busyAgentId: string | null;
|
||||
}): StopRunIntent => {
|
||||
if (input.status !== "connected") {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "not-connected",
|
||||
message: "Connect to gateway before stopping a run.",
|
||||
};
|
||||
}
|
||||
const sessionKey = input.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "missing-session-key",
|
||||
message: "Missing session key for agent.",
|
||||
};
|
||||
}
|
||||
if (input.busyAgentId === input.agentId) {
|
||||
return { kind: "skip-busy" };
|
||||
}
|
||||
return {
|
||||
kind: "allow",
|
||||
sessionKey,
|
||||
};
|
||||
};
|
||||
|
||||
export type NewSessionIntent =
|
||||
| { kind: "deny"; reason: "missing-agent" | "missing-session-key"; message: string }
|
||||
| { kind: "allow"; sessionKey: string };
|
||||
|
||||
export const planNewSessionIntent = (input: {
|
||||
hasAgent: boolean;
|
||||
sessionKey: string;
|
||||
}): NewSessionIntent => {
|
||||
if (!input.hasAgent) {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "missing-agent",
|
||||
message: "Failed to start new session: agent not found.",
|
||||
};
|
||||
}
|
||||
const sessionKey = input.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return {
|
||||
kind: "deny",
|
||||
reason: "missing-session-key",
|
||||
message: "Missing session key for agent.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "allow",
|
||||
sessionKey,
|
||||
};
|
||||
};
|
||||
|
||||
export type DraftFlushIntent =
|
||||
| { kind: "skip"; reason: "missing-agent-id" | "missing-pending-value" }
|
||||
| { kind: "flush"; agentId: string };
|
||||
|
||||
export const planDraftFlushIntent = (input: {
|
||||
agentId: string | null;
|
||||
hasPendingValue: boolean;
|
||||
}): DraftFlushIntent => {
|
||||
if (!input.agentId) {
|
||||
return {
|
||||
kind: "skip",
|
||||
reason: "missing-agent-id",
|
||||
};
|
||||
}
|
||||
if (!input.hasPendingValue) {
|
||||
return {
|
||||
kind: "skip",
|
||||
reason: "missing-pending-value",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "flush",
|
||||
agentId: input.agentId,
|
||||
};
|
||||
};
|
||||
|
||||
export type DraftTimerIntent =
|
||||
| { kind: "skip"; reason: "missing-agent-id" }
|
||||
| { kind: "schedule"; agentId: string; delayMs: number };
|
||||
|
||||
export const planDraftTimerIntent = (input: {
|
||||
agentId: string;
|
||||
delayMs?: number;
|
||||
}): DraftTimerIntent => {
|
||||
if (!input.agentId) {
|
||||
return {
|
||||
kind: "skip",
|
||||
reason: "missing-agent-id",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "schedule",
|
||||
agentId: input.agentId,
|
||||
delayMs: input.delayMs ?? 250,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
isWebchatSessionMutationBlockedError,
|
||||
syncGatewaySessionSettings,
|
||||
type GatewayClient,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
buildAgentInstruction,
|
||||
isMetaMarkdown,
|
||||
parseMetaMarkdown,
|
||||
} from "@/lib/text/message-extract";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { randomUUID } from "@/lib/uuid";
|
||||
import type { TranscriptAppendMeta } from "@/features/agents/state/transcript";
|
||||
|
||||
type SendDispatchAction =
|
||||
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
| { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta };
|
||||
|
||||
type SendDispatch = (action: SendDispatchAction) => void;
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const resolveLatestTranscriptTimestampMs = (agent: AgentState): number | null => {
|
||||
const entries = agent.transcriptEntries;
|
||||
let latest: number | null = null;
|
||||
if (Array.isArray(entries)) {
|
||||
for (const entry of entries) {
|
||||
const ts = entry?.timestampMs;
|
||||
if (typeof ts !== "number" || !Number.isFinite(ts)) continue;
|
||||
latest = latest === null ? ts : Math.max(latest, ts);
|
||||
}
|
||||
}
|
||||
if (latest !== null) return latest;
|
||||
const lines = agent.outputLines;
|
||||
for (const line of lines) {
|
||||
if (!isMetaMarkdown(line)) continue;
|
||||
const parsed = parseMetaMarkdown(line);
|
||||
const ts = parsed?.timestamp;
|
||||
if (typeof ts !== "number" || !Number.isFinite(ts)) continue;
|
||||
latest = latest === null ? ts : Math.max(latest, ts);
|
||||
}
|
||||
return latest;
|
||||
};
|
||||
|
||||
const resolveChatSendCompletionMode = (
|
||||
payload: unknown,
|
||||
optimisticRunId: string
|
||||
): "streaming-expected" | "terminal-immediate" => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return "terminal-immediate";
|
||||
}
|
||||
const value = payload as { status?: unknown; runId?: unknown };
|
||||
const status = typeof value.status === "string" ? value.status.trim().toLowerCase() : "";
|
||||
const runId = typeof value.runId === "string" ? value.runId.trim() : "";
|
||||
if ((status === "started" || status === "in_flight") && runId === optimisticRunId) {
|
||||
return "streaming-expected";
|
||||
}
|
||||
return "terminal-immediate";
|
||||
};
|
||||
|
||||
export async function sendChatMessageViaStudio(params: {
|
||||
client: GatewayClientLike;
|
||||
dispatch: SendDispatch;
|
||||
getAgent: (agentId: string) => AgentState | null;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
clearRunTracking?: (runId: string) => void;
|
||||
echoUserMessage?: boolean;
|
||||
now?: () => number;
|
||||
generateRunId?: () => string;
|
||||
}): Promise<void> {
|
||||
const trimmed = params.message.trim();
|
||||
if (!trimmed) return;
|
||||
const echoUserMessage = params.echoUserMessage !== false;
|
||||
|
||||
const generateRunId = params.generateRunId ?? (() => randomUUID());
|
||||
const now = params.now ?? (() => Date.now());
|
||||
|
||||
const agentId = params.agentId;
|
||||
const runId = generateRunId();
|
||||
|
||||
params.clearRunTracking?.(runId);
|
||||
|
||||
const agent = params.getAgent(agentId);
|
||||
if (!agent) {
|
||||
params.dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: "Error: Agent not found.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isResetCommand = /^\/(reset|new)(\s|$)/i.test(trimmed);
|
||||
if (isResetCommand) {
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: {
|
||||
outputLines: [],
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
lastResult: null,
|
||||
sessionEpoch: (agent.sessionEpoch ?? 0) + 1,
|
||||
transcriptEntries: [],
|
||||
lastHistoryRequestRevision: null,
|
||||
lastAppliedHistoryRequestId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const userTimestamp = now();
|
||||
const latestTranscriptTimestamp = resolveLatestTranscriptTimestampMs(agent);
|
||||
const optimisticUserOrderTimestamp =
|
||||
typeof latestTranscriptTimestamp === "number"
|
||||
? Math.max(userTimestamp, latestTranscriptTimestamp + 1)
|
||||
: userTimestamp;
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: {
|
||||
status: "running",
|
||||
runId,
|
||||
runStartedAt: userTimestamp,
|
||||
streamText: "",
|
||||
thinkingTrace: null,
|
||||
draft: "",
|
||||
...(echoUserMessage ? { lastUserMessage: trimmed } : {}),
|
||||
lastActivityAt: userTimestamp,
|
||||
},
|
||||
});
|
||||
if (echoUserMessage) {
|
||||
params.dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: `> ${trimmed}`,
|
||||
transcript: {
|
||||
source: "local-send",
|
||||
runId,
|
||||
sessionKey: params.sessionKey,
|
||||
timestampMs: optimisticUserOrderTimestamp,
|
||||
role: "user",
|
||||
kind: "user",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!params.sessionKey) {
|
||||
throw new Error("Missing session key for agent.");
|
||||
}
|
||||
|
||||
let createdSession = agent.sessionCreated;
|
||||
if (!agent.sessionSettingsSynced) {
|
||||
try {
|
||||
await syncGatewaySessionSettings({
|
||||
client: params.client as unknown as GatewayClient,
|
||||
sessionKey: params.sessionKey,
|
||||
model: agent.model ?? null,
|
||||
thinkingLevel: agent.thinkingLevel ?? null,
|
||||
execHost: agent.sessionExecHost,
|
||||
execSecurity: agent.sessionExecSecurity,
|
||||
execAsk: agent.sessionExecAsk,
|
||||
});
|
||||
createdSession = true;
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { sessionSettingsSynced: true, sessionCreated: true },
|
||||
});
|
||||
} catch (syncError) {
|
||||
if (!isWebchatSessionMutationBlockedError(syncError)) {
|
||||
throw syncError;
|
||||
}
|
||||
createdSession = true;
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { sessionSettingsSynced: true, sessionCreated: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sendResult = await params.client.call("chat.send", {
|
||||
sessionKey: params.sessionKey,
|
||||
message: buildAgentInstruction({ message: trimmed }),
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
});
|
||||
|
||||
if (!createdSession) {
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { sessionCreated: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (resolveChatSendCompletionMode(sendResult, runId) === "terminal-immediate") {
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: {
|
||||
status: "idle",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Gateway error";
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { status: "error", runId: null, runStartedAt: null, streamText: null, thinkingTrace: null },
|
||||
});
|
||||
params.dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: `Error: ${msg}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { GatewayStatus } from "./gatewayRestartPolicy";
|
||||
|
||||
export type ConfigMutationGateInput = {
|
||||
status: GatewayStatus;
|
||||
hasRunningAgents: boolean;
|
||||
nextMutationRequiresIdleAgents: boolean;
|
||||
hasActiveMutation: boolean;
|
||||
hasRestartBlockInProgress: boolean;
|
||||
queuedCount: number;
|
||||
};
|
||||
|
||||
export function shouldStartNextConfigMutation(input: ConfigMutationGateInput): boolean {
|
||||
if (input.status !== "connected") return false;
|
||||
if (input.queuedCount <= 0) return false;
|
||||
if (input.hasActiveMutation) return false;
|
||||
if (input.hasRestartBlockInProgress) return false;
|
||||
if (input.hasRunningAgents && input.nextMutationRequiresIdleAgents) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
type AgentPermissionsDraft,
|
||||
resolvePresetDefaultsForRole,
|
||||
updateAgentPermissionsViaStudio,
|
||||
} from "@/features/agents/operations/agentPermissionsOperation";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
planCreateAgentBootstrapCommands,
|
||||
type CreateBootstrapCommand,
|
||||
} from "@/features/agents/operations/createAgentBootstrapWorkflow";
|
||||
|
||||
type CreateCompletion = {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
};
|
||||
|
||||
type CreatedAgent = {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
export const CREATE_AGENT_DEFAULT_PERMISSIONS: Readonly<AgentPermissionsDraft> =
|
||||
Object.freeze(resolvePresetDefaultsForRole("autonomous"));
|
||||
|
||||
const resolveBootstrapErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return error.message || "Failed to apply default permissions.";
|
||||
}
|
||||
return "Failed to apply default permissions.";
|
||||
};
|
||||
|
||||
export async function applyCreateAgentBootstrapPermissions(params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
draft: AgentPermissionsDraft;
|
||||
loadAgents: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
await updateAgentPermissionsViaStudio({
|
||||
client: params.client,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
draft: params.draft,
|
||||
loadAgents: params.loadAgents,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCreateAgentBootstrapOperation(params: {
|
||||
completion: CreateCompletion;
|
||||
focusedAgentId: string | null;
|
||||
loadAgents: () => Promise<void>;
|
||||
findAgentById: (agentId: string) => CreatedAgent | null;
|
||||
applyDefaultPermissions: (input: { agentId: string; sessionKey: string }) => Promise<void>;
|
||||
refreshGatewayConfigSnapshot: () => Promise<unknown>;
|
||||
planCommands?: typeof planCreateAgentBootstrapCommands;
|
||||
}): Promise<CreateBootstrapCommand[]> {
|
||||
const plan = params.planCommands ?? planCreateAgentBootstrapCommands;
|
||||
|
||||
await params.loadAgents();
|
||||
let createdAgent = params.findAgentById(params.completion.agentId);
|
||||
if (!createdAgent) {
|
||||
await params.loadAgents();
|
||||
createdAgent = params.findAgentById(params.completion.agentId);
|
||||
}
|
||||
|
||||
let bootstrapErrorMessage: string | null = null;
|
||||
if (createdAgent) {
|
||||
try {
|
||||
await params.applyDefaultPermissions({
|
||||
agentId: createdAgent.agentId,
|
||||
sessionKey: createdAgent.sessionKey,
|
||||
});
|
||||
await params.refreshGatewayConfigSnapshot();
|
||||
} catch (error) {
|
||||
bootstrapErrorMessage = resolveBootstrapErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
return plan({
|
||||
completion: params.completion,
|
||||
createdAgent,
|
||||
bootstrapErrorMessage,
|
||||
focusedAgentId: params.focusedAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function executeCreateAgentBootstrapCommands(params: {
|
||||
commands: CreateBootstrapCommand[];
|
||||
setCreateAgentModalError: (message: string | null) => void;
|
||||
setGlobalError: (message: string) => void;
|
||||
setCreateAgentBlock: (value: null) => void;
|
||||
setCreateAgentModalOpen: (open: boolean) => void;
|
||||
flushPendingDraft: (agentId: string | null) => void;
|
||||
selectAgent: (agentId: string) => void;
|
||||
setInspectSidebarCapabilities: (agentId: string) => void;
|
||||
setMobilePaneChat: () => void;
|
||||
}): void {
|
||||
for (const command of params.commands) {
|
||||
if (command.kind === "set-create-modal-error") {
|
||||
params.setCreateAgentModalError(command.message);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-global-error") {
|
||||
params.setGlobalError(command.message);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-create-block") {
|
||||
params.setCreateAgentBlock(command.value);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-create-modal-open") {
|
||||
params.setCreateAgentModalOpen(command.open);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "flush-pending-draft") {
|
||||
params.flushPendingDraft(command.agentId);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "select-agent") {
|
||||
params.selectAgent(command.agentId);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-inspect-sidebar") {
|
||||
params.setInspectSidebarCapabilities(command.agentId);
|
||||
continue;
|
||||
}
|
||||
params.setMobilePaneChat();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export type CreateBootstrapFacts = {
|
||||
completion: { agentId: string; agentName: string };
|
||||
createdAgent: { agentId: string; sessionKey: string } | null;
|
||||
bootstrapErrorMessage: string | null;
|
||||
focusedAgentId: string | null;
|
||||
};
|
||||
|
||||
export type CreateBootstrapCommand =
|
||||
| { kind: "set-create-modal-error"; message: string | null }
|
||||
| { kind: "set-global-error"; message: string }
|
||||
| { kind: "set-create-block"; value: null }
|
||||
| { kind: "set-create-modal-open"; open: boolean }
|
||||
| { kind: "flush-pending-draft"; agentId: string | null }
|
||||
| { kind: "select-agent"; agentId: string }
|
||||
| { kind: "set-inspect-sidebar"; agentId: string; tab: "capabilities" }
|
||||
| { kind: "set-mobile-pane"; pane: "chat" };
|
||||
|
||||
const buildMissingCreatedAgentMessage = (agentName: string): string =>
|
||||
`Agent "${agentName}" was created, but Studio could not load it yet.`;
|
||||
|
||||
const buildBootstrapGlobalErrorMessage = (errorMessage: string): string =>
|
||||
`Agent created, but default permissions could not be applied: ${errorMessage}`;
|
||||
|
||||
const buildBootstrapModalErrorMessage = (errorMessage: string): string =>
|
||||
`Default permissions failed: ${errorMessage}`;
|
||||
|
||||
export function planCreateAgentBootstrapCommands(
|
||||
facts: CreateBootstrapFacts
|
||||
): CreateBootstrapCommand[] {
|
||||
if (!facts.createdAgent) {
|
||||
const message = buildMissingCreatedAgentMessage(facts.completion.agentName);
|
||||
return [
|
||||
{ kind: "set-create-modal-error", message },
|
||||
{ kind: "set-global-error", message },
|
||||
{ kind: "set-create-block", value: null },
|
||||
{ kind: "set-create-modal-open", open: false },
|
||||
];
|
||||
}
|
||||
|
||||
const commands: CreateBootstrapCommand[] = [];
|
||||
if (facts.bootstrapErrorMessage) {
|
||||
commands.push({
|
||||
kind: "set-global-error",
|
||||
message: buildBootstrapGlobalErrorMessage(facts.bootstrapErrorMessage),
|
||||
});
|
||||
}
|
||||
commands.push({ kind: "flush-pending-draft", agentId: facts.focusedAgentId });
|
||||
commands.push({ kind: "select-agent", agentId: facts.completion.agentId });
|
||||
commands.push({
|
||||
kind: "set-inspect-sidebar",
|
||||
agentId: facts.completion.agentId,
|
||||
tab: "capabilities",
|
||||
});
|
||||
commands.push({ kind: "set-mobile-pane", pane: "chat" });
|
||||
commands.push({
|
||||
kind: "set-create-modal-error",
|
||||
message: facts.bootstrapErrorMessage
|
||||
? buildBootstrapModalErrorMessage(facts.bootstrapErrorMessage)
|
||||
: null,
|
||||
});
|
||||
commands.push({ kind: "set-create-block", value: null });
|
||||
commands.push({ kind: "set-create-modal-open", open: false });
|
||||
return commands;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
buildCronJobCreateInput,
|
||||
type CronCreateDraft,
|
||||
} from "@/lib/cron/createPayloadBuilder";
|
||||
import {
|
||||
createCronJob as createCronJobDefault,
|
||||
filterCronJobsForAgent,
|
||||
listCronJobs as listCronJobsDefault,
|
||||
sortCronJobsByUpdatedAt,
|
||||
type CronJobCreateInput,
|
||||
type CronJobSummary,
|
||||
} from "@/lib/cron/types";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export const CRON_ACTION_BUSY_MESSAGE = "Please wait for the current cron action to finish.";
|
||||
|
||||
const resolveCreateAgentId = (agentId: string) => {
|
||||
const trimmed = agentId.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Failed to create cron job: missing agent id.");
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const resolveCreateErrorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : "Failed to create cron job.";
|
||||
|
||||
export type CronBusyState = {
|
||||
createBusy: boolean;
|
||||
runBusyJobId: string | null;
|
||||
deleteBusyJobId: string | null;
|
||||
};
|
||||
|
||||
type CronCreateDeps = {
|
||||
buildInput?: (agentId: string, draft: CronCreateDraft) => CronJobCreateInput;
|
||||
createCronJob?: (client: GatewayClient, input: CronJobCreateInput) => Promise<unknown>;
|
||||
listCronJobs?: (
|
||||
client: GatewayClient,
|
||||
params: { includeDisabled?: boolean }
|
||||
) => Promise<{ jobs: CronJobSummary[] }>;
|
||||
};
|
||||
|
||||
const isCronActionBusy = (busy: CronBusyState) =>
|
||||
busy.createBusy || Boolean(busy.runBusyJobId) || Boolean(busy.deleteBusyJobId);
|
||||
|
||||
export const performCronCreateFlow = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
draft: CronCreateDraft;
|
||||
busy: CronBusyState;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onError: (message: string | null) => void;
|
||||
onJobs: (jobs: CronJobSummary[]) => void;
|
||||
deps?: CronCreateDeps;
|
||||
}): Promise<"created"> => {
|
||||
if (isCronActionBusy(params.busy)) {
|
||||
params.onError(CRON_ACTION_BUSY_MESSAGE);
|
||||
throw new Error(CRON_ACTION_BUSY_MESSAGE);
|
||||
}
|
||||
|
||||
let resolvedAgentId = "";
|
||||
try {
|
||||
resolvedAgentId = resolveCreateAgentId(params.agentId);
|
||||
} catch (error) {
|
||||
const message = resolveCreateErrorMessage(error);
|
||||
params.onError(message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const buildInput = params.deps?.buildInput ?? buildCronJobCreateInput;
|
||||
const createCronJob = params.deps?.createCronJob ?? createCronJobDefault;
|
||||
const listCronJobs = params.deps?.listCronJobs ?? listCronJobsDefault;
|
||||
|
||||
params.onBusyChange(true);
|
||||
params.onError(null);
|
||||
|
||||
try {
|
||||
const input = buildInput(resolvedAgentId, params.draft);
|
||||
await createCronJob(params.client, input);
|
||||
const listResult = await listCronJobs(params.client, { includeDisabled: true });
|
||||
const jobs = sortCronJobsByUpdatedAt(filterCronJobsForAgent(listResult.jobs, resolvedAgentId));
|
||||
params.onJobs(jobs);
|
||||
return "created";
|
||||
} catch (error) {
|
||||
const message = resolveCreateErrorMessage(error);
|
||||
params.onError(message);
|
||||
throw error instanceof Error ? error : new Error(message);
|
||||
} finally {
|
||||
params.onBusyChange(false);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { fetchJson as defaultFetchJson } from "@/lib/http";
|
||||
import {
|
||||
removeCronJobsForAgentWithBackup,
|
||||
restoreCronJobs,
|
||||
type CronJobRestoreInput,
|
||||
} from "@/lib/cron/types";
|
||||
import { deleteGatewayAgent } from "@/lib/gateway/agentConfig";
|
||||
|
||||
type FetchJson = typeof defaultFetchJson;
|
||||
|
||||
export type GatewayAgentStateMove = { from: string; to: string };
|
||||
|
||||
export type TrashAgentStateResult = {
|
||||
trashDir: string;
|
||||
moved: GatewayAgentStateMove[];
|
||||
};
|
||||
|
||||
export type RestoreAgentStateResult = {
|
||||
restored: GatewayAgentStateMove[];
|
||||
};
|
||||
|
||||
type DeleteAgentTransactionDeps = {
|
||||
trashAgentState: (agentId: string) => Promise<TrashAgentStateResult>;
|
||||
restoreAgentState: (agentId: string, trashDir: string) => Promise<RestoreAgentStateResult>;
|
||||
removeCronJobsForAgentWithBackup: (agentId: string) => Promise<CronJobRestoreInput[]>;
|
||||
restoreCronJobs: (jobs: CronJobRestoreInput[]) => Promise<void>;
|
||||
deleteGatewayAgent: (agentId: string) => Promise<void>;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
export type DeleteAgentTransactionResult = {
|
||||
trashed: TrashAgentStateResult;
|
||||
restored: RestoreAgentStateResult | null;
|
||||
};
|
||||
|
||||
const runDeleteFlow = async (
|
||||
deps: DeleteAgentTransactionDeps,
|
||||
agentId: string
|
||||
): Promise<DeleteAgentTransactionResult> => {
|
||||
const trimmedAgentId = agentId.trim();
|
||||
if (!trimmedAgentId) {
|
||||
throw new Error("Agent id is required.");
|
||||
}
|
||||
|
||||
const trashed = await deps.trashAgentState(trimmedAgentId);
|
||||
let removedCronJobs: CronJobRestoreInput[] = [];
|
||||
|
||||
try {
|
||||
removedCronJobs = await deps.removeCronJobsForAgentWithBackup(trimmedAgentId);
|
||||
await deps.deleteGatewayAgent(trimmedAgentId);
|
||||
return { trashed, restored: null };
|
||||
} catch (err) {
|
||||
if (removedCronJobs.length > 0) {
|
||||
try {
|
||||
await deps.restoreCronJobs(removedCronJobs);
|
||||
} catch (restoreCronErr) {
|
||||
deps.logError?.("Failed to restore removed cron jobs.", restoreCronErr);
|
||||
}
|
||||
}
|
||||
if (trashed.moved.length > 0) {
|
||||
try {
|
||||
await deps.restoreAgentState(trimmedAgentId, trashed.trashDir);
|
||||
} catch (restoreErr) {
|
||||
deps.logError?.("Failed to restore trashed agent state.", restoreErr);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteAgentViaStudio = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
fetchJson?: FetchJson;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
}): Promise<DeleteAgentTransactionResult> => {
|
||||
const fetchJson = params.fetchJson ?? defaultFetchJson;
|
||||
const logError = params.logError ?? ((message, error) => console.error(message, error));
|
||||
|
||||
return runDeleteFlow(
|
||||
{
|
||||
trashAgentState: async (agentId) => {
|
||||
const { result } = await fetchJson<{ result: TrashAgentStateResult }>(
|
||||
"/api/gateway/agent-state",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ agentId }),
|
||||
}
|
||||
);
|
||||
return result;
|
||||
},
|
||||
restoreAgentState: async (agentId, trashDir) => {
|
||||
const { result } = await fetchJson<{ result: RestoreAgentStateResult }>(
|
||||
"/api/gateway/agent-state",
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ agentId, trashDir }),
|
||||
}
|
||||
);
|
||||
return result;
|
||||
},
|
||||
removeCronJobsForAgentWithBackup: async (agentId) => {
|
||||
return await removeCronJobsForAgentWithBackup(params.client, agentId);
|
||||
},
|
||||
restoreCronJobs: async (jobs) => {
|
||||
await restoreCronJobs(params.client, jobs);
|
||||
},
|
||||
deleteGatewayAgent: async (agentId) => {
|
||||
await deleteGatewayAgent({ client: params.client, agentId });
|
||||
},
|
||||
logError,
|
||||
},
|
||||
params.agentId
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
export type SummarySnapshotSeed = Pick<AgentState, "sessionCreated" | "sessionKey">;
|
||||
|
||||
export type SummarySnapshotIntent =
|
||||
| { kind: "skip" }
|
||||
| {
|
||||
kind: "fetch";
|
||||
keys: string[];
|
||||
limit: number;
|
||||
maxChars: number;
|
||||
};
|
||||
|
||||
export type ReconcileEligibility = {
|
||||
shouldCheck: boolean;
|
||||
reason: "ok" | "not-running" | "missing-run-id" | "not-session-created";
|
||||
};
|
||||
|
||||
const SUMMARY_PREVIEW_LIMIT = 8;
|
||||
const SUMMARY_PREVIEW_MAX_CHARS = 240;
|
||||
|
||||
export const resolveSummarySnapshotKeys = (params: {
|
||||
agents: Array<{ sessionCreated: boolean; sessionKey: string }>;
|
||||
maxKeys: number;
|
||||
}): string[] => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
params.agents
|
||||
.filter((agent) => agent.sessionCreated)
|
||||
.map((agent) => agent.sessionKey)
|
||||
.filter((key): key is string => typeof key === "string" && key.trim().length > 0)
|
||||
)
|
||||
).slice(0, params.maxKeys);
|
||||
};
|
||||
|
||||
export const resolveSummarySnapshotIntent = (params: {
|
||||
agents: SummarySnapshotSeed[];
|
||||
maxKeys: number;
|
||||
}): SummarySnapshotIntent => {
|
||||
const keys = resolveSummarySnapshotKeys({
|
||||
agents: params.agents,
|
||||
maxKeys: params.maxKeys,
|
||||
});
|
||||
if (keys.length === 0) {
|
||||
return { kind: "skip" };
|
||||
}
|
||||
return {
|
||||
kind: "fetch",
|
||||
keys,
|
||||
limit: SUMMARY_PREVIEW_LIMIT,
|
||||
maxChars: SUMMARY_PREVIEW_MAX_CHARS,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveReconcileEligibility = (params: {
|
||||
status: "running" | "idle" | "error";
|
||||
sessionCreated: boolean;
|
||||
runId: string | null;
|
||||
}): ReconcileEligibility => {
|
||||
if (params.status !== "running") {
|
||||
return { shouldCheck: false, reason: "not-running" };
|
||||
}
|
||||
if (!params.sessionCreated) {
|
||||
return { shouldCheck: false, reason: "not-session-created" };
|
||||
}
|
||||
const runId = params.runId?.trim() ?? "";
|
||||
if (!runId) {
|
||||
return { shouldCheck: false, reason: "missing-run-id" };
|
||||
}
|
||||
return { shouldCheck: true, reason: "ok" };
|
||||
};
|
||||
|
||||
export const buildReconcileTerminalPatch = (params: {
|
||||
outcome: "ok" | "error";
|
||||
}): {
|
||||
status: "idle" | "error";
|
||||
runId: null;
|
||||
runStartedAt: null;
|
||||
streamText: null;
|
||||
thinkingTrace: null;
|
||||
} => {
|
||||
return {
|
||||
status: params.outcome === "error" ? "error" : "idle",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveReconcileWaitOutcome = (status: unknown): "ok" | "error" | null => {
|
||||
if (status === "ok" || status === "error") {
|
||||
return status;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { readConfigAgentList } from "@/lib/gateway/agentConfig";
|
||||
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||
|
||||
export type GatewayConnectionStatus = "disconnected" | "connecting" | "connected";
|
||||
|
||||
type RecordLike = Record<string, unknown>;
|
||||
|
||||
const asRecord = (value: unknown): RecordLike | null => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as RecordLike;
|
||||
};
|
||||
|
||||
export const resolveGatewayConfigRecord = (
|
||||
snapshot: GatewayModelPolicySnapshot | null
|
||||
): RecordLike | null => {
|
||||
return asRecord(snapshot?.config ?? null);
|
||||
};
|
||||
|
||||
export const resolveSandboxRepairAgentIds = (
|
||||
snapshot: GatewayModelPolicySnapshot | null
|
||||
): string[] => {
|
||||
const baseConfig = resolveGatewayConfigRecord(snapshot);
|
||||
if (!baseConfig) return [];
|
||||
|
||||
const list = readConfigAgentList(baseConfig);
|
||||
return list
|
||||
.filter((entry) => {
|
||||
const sandbox = asRecord(entry.sandbox);
|
||||
const mode = typeof sandbox?.mode === "string" ? sandbox.mode.trim().toLowerCase() : "";
|
||||
if (mode !== "all") return false;
|
||||
|
||||
const tools = asRecord(entry.tools);
|
||||
const sandboxBlock = asRecord(tools?.sandbox);
|
||||
const sandboxTools = asRecord(sandboxBlock?.tools);
|
||||
const allow = sandboxTools?.allow;
|
||||
return Array.isArray(allow) && allow.length === 0;
|
||||
})
|
||||
.map((entry) => entry.id);
|
||||
};
|
||||
|
||||
export type SandboxRepairIntent =
|
||||
| { kind: "skip"; reason: "not-connected" | "already-attempted" | "no-eligible-agents" }
|
||||
| { kind: "repair"; agentIds: string[] };
|
||||
|
||||
export const resolveSandboxRepairIntent = (params: {
|
||||
status: GatewayConnectionStatus;
|
||||
attempted: boolean;
|
||||
snapshot: GatewayModelPolicySnapshot | null;
|
||||
}): SandboxRepairIntent => {
|
||||
if (params.status !== "connected") {
|
||||
return { kind: "skip", reason: "not-connected" };
|
||||
}
|
||||
if (params.attempted) {
|
||||
return { kind: "skip", reason: "already-attempted" };
|
||||
}
|
||||
|
||||
const agentIds = resolveSandboxRepairAgentIds(params.snapshot);
|
||||
if (agentIds.length === 0) {
|
||||
return { kind: "skip", reason: "no-eligible-agents" };
|
||||
}
|
||||
|
||||
return { kind: "repair", agentIds };
|
||||
};
|
||||
|
||||
export const shouldRefreshGatewayConfigForSettingsRoute = (params: {
|
||||
status: GatewayConnectionStatus;
|
||||
settingsRouteActive: boolean;
|
||||
inspectSidebarAgentId: string | null;
|
||||
}): boolean => {
|
||||
if (!params.settingsRouteActive) return false;
|
||||
if (!params.inspectSidebarAgentId) return false;
|
||||
if (params.status !== "connected") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export type GatewayModelsSyncIntent = { kind: "clear" } | { kind: "load" };
|
||||
|
||||
export const resolveGatewayModelsSyncIntent = (params: {
|
||||
status: GatewayConnectionStatus;
|
||||
}): GatewayModelsSyncIntent => {
|
||||
if (params.status !== "connected") {
|
||||
return { kind: "clear" };
|
||||
}
|
||||
return { kind: "load" };
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export type GatewayStatus = "disconnected" | "connecting" | "connected";
|
||||
|
||||
export type RestartObservation = {
|
||||
sawDisconnect: boolean;
|
||||
};
|
||||
|
||||
export function observeGatewayRestart(
|
||||
prev: RestartObservation,
|
||||
status: GatewayStatus
|
||||
): { next: RestartObservation; restartComplete: boolean } {
|
||||
const sawDisconnect = prev.sawDisconnect || status !== "connected";
|
||||
return {
|
||||
next: { sawDisconnect },
|
||||
restartComplete: status === "connected" && sawDisconnect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
export type HistoryRequestIntent =
|
||||
| {
|
||||
kind: "skip";
|
||||
reason: "missing-agent" | "session-not-created" | "missing-session-key" | "in-flight";
|
||||
}
|
||||
| {
|
||||
kind: "fetch";
|
||||
sessionKey: string;
|
||||
limit: number;
|
||||
requestRevision: number;
|
||||
requestEpoch: number;
|
||||
requestId: string;
|
||||
loadedAt: number;
|
||||
};
|
||||
|
||||
export type HistoryResponseDisposition =
|
||||
| {
|
||||
kind: "drop";
|
||||
reason:
|
||||
| "session-key-changed"
|
||||
| "session-epoch-changed"
|
||||
| "transcript-revision-changed";
|
||||
}
|
||||
| {
|
||||
kind: "apply";
|
||||
};
|
||||
|
||||
const resolveHistoryFetchLimit = (params: {
|
||||
requestedLimit?: number;
|
||||
defaultLimit: number;
|
||||
maxLimit: number;
|
||||
}): number => {
|
||||
const requested = params.requestedLimit;
|
||||
if (typeof requested !== "number" || !Number.isFinite(requested) || requested <= 0) {
|
||||
return params.defaultLimit;
|
||||
}
|
||||
return Math.min(params.maxLimit, Math.floor(requested));
|
||||
};
|
||||
|
||||
export const resolveHistoryRequestIntent = (params: {
|
||||
agent: AgentState | null;
|
||||
requestedLimit?: number;
|
||||
maxLimit: number;
|
||||
defaultLimit: number;
|
||||
inFlightSessionKeys: Set<string>;
|
||||
requestId: string;
|
||||
loadedAt: number;
|
||||
}): HistoryRequestIntent => {
|
||||
if (!params.agent) {
|
||||
return { kind: "skip", reason: "missing-agent" };
|
||||
}
|
||||
if (!params.agent.sessionCreated) {
|
||||
return { kind: "skip", reason: "session-not-created" };
|
||||
}
|
||||
const sessionKey = params.agent.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return { kind: "skip", reason: "missing-session-key" };
|
||||
}
|
||||
if (params.inFlightSessionKeys.has(sessionKey)) {
|
||||
return { kind: "skip", reason: "in-flight" };
|
||||
}
|
||||
return {
|
||||
kind: "fetch",
|
||||
sessionKey,
|
||||
limit: resolveHistoryFetchLimit({
|
||||
requestedLimit: params.requestedLimit,
|
||||
defaultLimit: params.defaultLimit,
|
||||
maxLimit: params.maxLimit,
|
||||
}),
|
||||
requestRevision: params.agent.transcriptRevision ?? params.agent.outputLines.length,
|
||||
requestEpoch: params.agent.sessionEpoch ?? 0,
|
||||
requestId: params.requestId,
|
||||
loadedAt: params.loadedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveHistoryResponseDisposition = (params: {
|
||||
latestAgent: AgentState | null;
|
||||
expectedSessionKey: string;
|
||||
requestEpoch: number;
|
||||
requestRevision: number;
|
||||
}): HistoryResponseDisposition => {
|
||||
const latest = params.latestAgent;
|
||||
if (!latest || latest.sessionKey.trim() !== params.expectedSessionKey) {
|
||||
return { kind: "drop", reason: "session-key-changed" };
|
||||
}
|
||||
if ((latest.sessionEpoch ?? 0) !== params.requestEpoch) {
|
||||
return { kind: "drop", reason: "session-epoch-changed" };
|
||||
}
|
||||
const latestRevision = latest.transcriptRevision ?? latest.outputLines.length;
|
||||
if (latestRevision !== params.requestRevision) {
|
||||
return { kind: "drop", reason: "transcript-revision-changed" };
|
||||
}
|
||||
return { kind: "apply" };
|
||||
};
|
||||
|
||||
export const buildHistoryMetadataPatch = (params: {
|
||||
loadedAt: number;
|
||||
fetchedCount: number;
|
||||
limit: number;
|
||||
requestId: string;
|
||||
}): Pick<
|
||||
AgentState,
|
||||
| "historyLoadedAt"
|
||||
| "historyFetchLimit"
|
||||
| "historyFetchedCount"
|
||||
| "historyMaybeTruncated"
|
||||
| "lastAppliedHistoryRequestId"
|
||||
> => {
|
||||
return {
|
||||
historyLoadedAt: params.loadedAt,
|
||||
historyFetchLimit: params.limit,
|
||||
historyFetchedCount: params.fetchedCount,
|
||||
historyMaybeTruncated: params.fetchedCount >= params.limit,
|
||||
lastAppliedHistoryRequestId: params.requestId,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
buildHistoryMetadataPatch,
|
||||
resolveHistoryRequestIntent,
|
||||
resolveHistoryResponseDisposition,
|
||||
} from "@/features/agents/operations/historyLifecycleWorkflow";
|
||||
import {
|
||||
buildHistoryLines,
|
||||
buildHistorySyncPatch,
|
||||
resolveHistoryRunStatePatch,
|
||||
} from "@/features/agents/state/runtimeEventBridge";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import {
|
||||
areTranscriptEntriesEqual,
|
||||
buildOutputLinesFromTranscriptEntries,
|
||||
buildTranscriptEntriesFromLines,
|
||||
mergeTranscriptEntriesWithHistory,
|
||||
sortTranscriptEntries,
|
||||
type TranscriptEntry,
|
||||
} from "@/features/agents/state/transcript";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
|
||||
// History sync is the canonical transcript recovery boundary. Runtime streams can be optimistic
|
||||
// or transport-specific, so this operation pulls `chat.history`, validates that the response is
|
||||
// still current for the agent/session, and then merges canonical entries back into local state.
|
||||
type ChatHistoryMessage = Record<string, unknown>;
|
||||
|
||||
type ChatHistoryResult = {
|
||||
sessionKey: string;
|
||||
messages: ChatHistoryMessage[];
|
||||
};
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: <T = unknown>(method: string, params: unknown) => Promise<T>;
|
||||
};
|
||||
|
||||
export type HistorySyncCommand =
|
||||
| { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
| { kind: "logMetric"; metric: string; meta: Record<string, unknown> }
|
||||
| { kind: "logError"; message: string; error: unknown }
|
||||
| { kind: "noop"; reason: string };
|
||||
|
||||
type HistorySyncDispatchAction = {
|
||||
type: "updateAgent";
|
||||
agentId: string;
|
||||
patch: Partial<AgentState>;
|
||||
};
|
||||
|
||||
type RunHistorySyncOperationParams = {
|
||||
client: GatewayClientLike;
|
||||
agentId: string;
|
||||
requestedLimit?: number;
|
||||
getAgent: (agentId: string) => AgentState | null;
|
||||
inFlightSessionKeys: Set<string>;
|
||||
requestId: string;
|
||||
loadedAt: number;
|
||||
defaultLimit: number;
|
||||
maxLimit: number;
|
||||
transcriptV2Enabled: boolean;
|
||||
};
|
||||
|
||||
export const executeHistorySyncCommands = (params: {
|
||||
commands: HistorySyncCommand[];
|
||||
dispatch: (action: HistorySyncDispatchAction) => void;
|
||||
logMetric: (metric: string, meta?: unknown) => void;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) => {
|
||||
for (const command of params.commands) {
|
||||
if (command.kind === "dispatchUpdateAgent") {
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId: command.agentId,
|
||||
patch: command.patch,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "logMetric") {
|
||||
params.logMetric(command.metric, command.meta);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "logError") {
|
||||
if (params.isDisconnectLikeError(command.error)) continue;
|
||||
params.logError(command.message, command.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const areStringArraysEqual = (left: string[], right: string[]): boolean => {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (left[i] !== right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const scoreResolvedRunAssistantEntry = (entry: TranscriptEntry): number => {
|
||||
let score = 0;
|
||||
if (entry.confirmed) score += 4;
|
||||
if (entry.source === "runtime-chat") score += 2;
|
||||
if (entry.source === "history") score += 1;
|
||||
if (typeof entry.timestampMs === "number" && Number.isFinite(entry.timestampMs)) {
|
||||
score += 1;
|
||||
}
|
||||
return score;
|
||||
};
|
||||
|
||||
const collapseNonActiveRunAssistantDuplicates = (
|
||||
entries: TranscriptEntry[],
|
||||
activeRunId: string
|
||||
): TranscriptEntry[] => {
|
||||
const normalizedActiveRunId = activeRunId.trim();
|
||||
const next: TranscriptEntry[] = [];
|
||||
const byRunAssistantText = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
const normalizedRunId = entry.runId?.trim() ?? "";
|
||||
const isResolvedRunAssistant =
|
||||
normalizedRunId.length > 0 &&
|
||||
normalizedRunId !== normalizedActiveRunId &&
|
||||
entry.kind === "assistant" &&
|
||||
entry.role === "assistant";
|
||||
if (!isResolvedRunAssistant) {
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
const dedupeKey = normalizeAssistantDisplayText(entry.text);
|
||||
if (!dedupeKey) {
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
const runScopedKey = `${normalizedRunId}:${dedupeKey}`;
|
||||
const existingIndex = byRunAssistantText.get(runScopedKey);
|
||||
if (existingIndex === undefined) {
|
||||
byRunAssistantText.set(runScopedKey, next.length);
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
const current = next[existingIndex];
|
||||
if (!current) {
|
||||
byRunAssistantText.set(runScopedKey, next.length);
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
const currentScore = scoreResolvedRunAssistantEntry(current);
|
||||
const nextScore = scoreResolvedRunAssistantEntry(entry);
|
||||
const shouldReplace =
|
||||
nextScore > currentScore ||
|
||||
(nextScore === currentScore && entry.sequenceKey > current.sequenceKey);
|
||||
if (shouldReplace) {
|
||||
next[existingIndex] = entry;
|
||||
}
|
||||
}
|
||||
return sortTranscriptEntries(next);
|
||||
};
|
||||
|
||||
export const runHistorySyncOperation = async (
|
||||
params: RunHistorySyncOperationParams
|
||||
): Promise<HistorySyncCommand[]> => {
|
||||
const requestAgent = params.getAgent(params.agentId);
|
||||
const requestIntent = resolveHistoryRequestIntent({
|
||||
agent: requestAgent,
|
||||
requestedLimit: params.requestedLimit,
|
||||
maxLimit: params.maxLimit,
|
||||
defaultLimit: params.defaultLimit,
|
||||
inFlightSessionKeys: params.inFlightSessionKeys,
|
||||
requestId: params.requestId,
|
||||
loadedAt: params.loadedAt,
|
||||
});
|
||||
if (requestIntent.kind === "skip") {
|
||||
return [{ kind: "noop", reason: requestIntent.reason }];
|
||||
}
|
||||
|
||||
params.inFlightSessionKeys.add(requestIntent.sessionKey);
|
||||
const commands: HistorySyncCommand[] = [
|
||||
{
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: params.agentId,
|
||||
patch: {
|
||||
lastHistoryRequestRevision: requestIntent.requestRevision,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const result = await params.client.call<ChatHistoryResult>("chat.history", {
|
||||
sessionKey: requestIntent.sessionKey,
|
||||
limit: requestIntent.limit,
|
||||
});
|
||||
const latest = params.getAgent(params.agentId);
|
||||
const responseDisposition = resolveHistoryResponseDisposition({
|
||||
latestAgent: latest,
|
||||
expectedSessionKey: requestIntent.sessionKey,
|
||||
requestEpoch: requestIntent.requestEpoch,
|
||||
requestRevision: requestIntent.requestRevision,
|
||||
});
|
||||
const historyMessages = result.messages ?? [];
|
||||
const metadataPatch: Partial<AgentState> = buildHistoryMetadataPatch({
|
||||
loadedAt: requestIntent.loadedAt,
|
||||
fetchedCount: historyMessages.length,
|
||||
limit: requestIntent.limit,
|
||||
requestId: requestIntent.requestId,
|
||||
});
|
||||
|
||||
if (responseDisposition.kind === "drop") {
|
||||
const reason = responseDisposition.reason.replace(/-/g, "_");
|
||||
commands.push({
|
||||
kind: "logMetric",
|
||||
metric: "history_response_dropped_stale",
|
||||
meta: {
|
||||
reason,
|
||||
agentId: params.agentId,
|
||||
requestId: requestIntent.requestId,
|
||||
},
|
||||
});
|
||||
return commands;
|
||||
}
|
||||
|
||||
if (!latest) {
|
||||
return commands;
|
||||
}
|
||||
|
||||
if (params.transcriptV2Enabled) {
|
||||
const existingEntries = Array.isArray(latest.transcriptEntries)
|
||||
? latest.transcriptEntries
|
||||
: buildTranscriptEntriesFromLines({
|
||||
lines: latest.outputLines,
|
||||
sessionKey: latest.sessionKey,
|
||||
source: "legacy",
|
||||
startSequence: 0,
|
||||
confirmed: true,
|
||||
});
|
||||
const history = buildHistoryLines(historyMessages);
|
||||
const runStatePatch = resolveHistoryRunStatePatch({
|
||||
status: latest.status,
|
||||
runId: latest.runId,
|
||||
lastRole: history.lastRole,
|
||||
lastUserAt: history.lastUserAt,
|
||||
loadedAt: requestIntent.loadedAt,
|
||||
});
|
||||
const normalizedLastAssistant = history.lastAssistant
|
||||
? normalizeAssistantDisplayText(history.lastAssistant)
|
||||
: null;
|
||||
const rawHistoryEntries = buildTranscriptEntriesFromLines({
|
||||
lines: history.lines,
|
||||
sessionKey: requestIntent.sessionKey,
|
||||
source: "history",
|
||||
startSequence: latest.transcriptSequenceCounter ?? existingEntries.length,
|
||||
confirmed: true,
|
||||
});
|
||||
// History entry ids are session-scoped and occurrence-aware so repeated assistant/user text
|
||||
// can still merge deterministically when canonical history is replayed.
|
||||
const historyEntryOccurrenceByKey = new Map<string, number>();
|
||||
const historyEntries = rawHistoryEntries.map((entry) => {
|
||||
const identityKey = `${entry.kind}:${entry.role}:${entry.timestampMs ?? "none"}:${entry.fingerprint}`;
|
||||
const occurrence = historyEntryOccurrenceByKey.get(identityKey) ?? 0;
|
||||
historyEntryOccurrenceByKey.set(identityKey, occurrence + 1);
|
||||
return {
|
||||
...entry,
|
||||
entryId: `history:${requestIntent.sessionKey}:${identityKey}:occ:${occurrence}`,
|
||||
};
|
||||
});
|
||||
const merged = mergeTranscriptEntriesWithHistory({
|
||||
existingEntries,
|
||||
historyEntries,
|
||||
});
|
||||
const activeRunId = latest.status === "running" ? (latest.runId?.trim() ?? "") : "";
|
||||
const finalEntries = collapseNonActiveRunAssistantDuplicates(merged.entries, activeRunId);
|
||||
if (merged.conflictCount > 0) {
|
||||
commands.push({
|
||||
kind: "logMetric",
|
||||
metric: "transcript_merge_conflicts",
|
||||
meta: {
|
||||
agentId: params.agentId,
|
||||
requestId: requestIntent.requestId,
|
||||
conflictCount: merged.conflictCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
const mergedLines = buildOutputLinesFromTranscriptEntries(finalEntries);
|
||||
const transcriptChanged = !areTranscriptEntriesEqual(existingEntries, finalEntries);
|
||||
const linesChanged = !areStringArraysEqual(latest.outputLines, mergedLines);
|
||||
commands.push({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: params.agentId,
|
||||
patch: {
|
||||
...metadataPatch,
|
||||
...(runStatePatch ?? {}),
|
||||
...(transcriptChanged || linesChanged
|
||||
? {
|
||||
transcriptEntries: finalEntries,
|
||||
outputLines: mergedLines,
|
||||
}
|
||||
: {}),
|
||||
...(normalizedLastAssistant ? { lastResult: normalizedLastAssistant } : {}),
|
||||
...(normalizedLastAssistant ? { latestPreview: normalizedLastAssistant } : {}),
|
||||
...(typeof history.lastAssistantAt === "number"
|
||||
? { lastAssistantMessageAt: history.lastAssistantAt }
|
||||
: {}),
|
||||
...(history.lastUser ? { lastUserMessage: history.lastUser } : {}),
|
||||
},
|
||||
});
|
||||
return commands;
|
||||
}
|
||||
|
||||
const patch = buildHistorySyncPatch({
|
||||
messages: historyMessages,
|
||||
currentLines: latest.outputLines,
|
||||
loadedAt: requestIntent.loadedAt,
|
||||
status: latest.status,
|
||||
runId: latest.runId,
|
||||
});
|
||||
commands.push({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: params.agentId,
|
||||
patch: {
|
||||
...patch,
|
||||
...metadataPatch,
|
||||
},
|
||||
});
|
||||
return commands;
|
||||
} catch (err) {
|
||||
commands.push({
|
||||
kind: "logError",
|
||||
message: err instanceof Error ? err.message : "Failed to load chat history.",
|
||||
error: err,
|
||||
});
|
||||
return commands;
|
||||
} finally {
|
||||
params.inFlightSessionKeys.delete(requestIntent.sessionKey);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { parseAgentIdFromSessionKey } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export type LatestUpdateKind = "heartbeat" | "cron" | null;
|
||||
|
||||
export type LatestUpdateIntent =
|
||||
| { kind: "reset" }
|
||||
| {
|
||||
kind: "fetch-heartbeat";
|
||||
agentId: string;
|
||||
sessionLimit: number;
|
||||
historyLimit: number;
|
||||
}
|
||||
| { kind: "fetch-cron"; agentId: string }
|
||||
| { kind: "noop" };
|
||||
|
||||
const SPECIAL_UPDATE_HEARTBEAT_RE = /\bheartbeat\b/i;
|
||||
const SPECIAL_UPDATE_CRON_RE = /\bcron\b/i;
|
||||
const HEARTBEAT_SESSION_LIMIT = 48;
|
||||
const HEARTBEAT_HISTORY_LIMIT = 200;
|
||||
|
||||
export const resolveLatestUpdateKind = (message: string): LatestUpdateKind => {
|
||||
const lowered = message.toLowerCase();
|
||||
const heartbeatIndex = lowered.search(SPECIAL_UPDATE_HEARTBEAT_RE);
|
||||
const cronIndex = lowered.search(SPECIAL_UPDATE_CRON_RE);
|
||||
if (heartbeatIndex === -1 && cronIndex === -1) return null;
|
||||
if (heartbeatIndex === -1) return "cron";
|
||||
if (cronIndex === -1) return "heartbeat";
|
||||
return cronIndex > heartbeatIndex ? "cron" : "heartbeat";
|
||||
};
|
||||
|
||||
export const resolveLatestUpdateIntent = (params: {
|
||||
message: string;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
hasExistingOverride: boolean;
|
||||
}): LatestUpdateIntent => {
|
||||
const kind = resolveLatestUpdateKind(params.message);
|
||||
if (!kind) {
|
||||
return params.hasExistingOverride ? { kind: "reset" } : { kind: "noop" };
|
||||
}
|
||||
if (kind === "heartbeat") {
|
||||
const resolvedAgentId =
|
||||
params.agentId.trim() || parseAgentIdFromSessionKey(params.sessionKey) || "";
|
||||
if (!resolvedAgentId) {
|
||||
return { kind: "reset" };
|
||||
}
|
||||
return {
|
||||
kind: "fetch-heartbeat",
|
||||
agentId: resolvedAgentId,
|
||||
sessionLimit: HEARTBEAT_SESSION_LIMIT,
|
||||
historyLimit: HEARTBEAT_HISTORY_LIMIT,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "fetch-cron",
|
||||
agentId: params.agentId.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildLatestUpdatePatch = (
|
||||
content: string,
|
||||
kind?: "heartbeat" | "cron"
|
||||
): {
|
||||
latestOverride: string | null;
|
||||
latestOverrideKind: "heartbeat" | "cron" | null;
|
||||
} => {
|
||||
return {
|
||||
latestOverride: content || null,
|
||||
latestOverrideKind: content && kind ? kind : null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,453 @@
|
||||
import type { GatewayStatus } from "@/features/agents/operations/gatewayRestartPolicy";
|
||||
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
||||
import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue";
|
||||
|
||||
export type MutationKind = "create-agent" | "rename-agent" | "delete-agent";
|
||||
|
||||
export type MutationBlockPhase = "queued" | "mutating" | "awaiting-restart";
|
||||
|
||||
export type MutationBlockState = {
|
||||
kind: MutationKind;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
phase: MutationBlockPhase;
|
||||
startedAt: number;
|
||||
sawDisconnect: boolean;
|
||||
};
|
||||
|
||||
export type MutationStartGuardResult =
|
||||
| { kind: "allow" }
|
||||
| {
|
||||
kind: "deny";
|
||||
reason: "not-connected" | "create-block-active" | "rename-block-active" | "delete-block-active";
|
||||
};
|
||||
|
||||
export const resolveMutationStartGuard = (params: {
|
||||
status: "connected" | "connecting" | "disconnected";
|
||||
hasCreateBlock: boolean;
|
||||
hasRenameBlock: boolean;
|
||||
hasDeleteBlock: boolean;
|
||||
}): MutationStartGuardResult => {
|
||||
if (params.status !== "connected") {
|
||||
return { kind: "deny", reason: "not-connected" };
|
||||
}
|
||||
if (params.hasCreateBlock) {
|
||||
return { kind: "deny", reason: "create-block-active" };
|
||||
}
|
||||
if (params.hasRenameBlock) {
|
||||
return { kind: "deny", reason: "rename-block-active" };
|
||||
}
|
||||
if (params.hasDeleteBlock) {
|
||||
return { kind: "deny", reason: "delete-block-active" };
|
||||
}
|
||||
return { kind: "allow" };
|
||||
};
|
||||
|
||||
export const buildQueuedMutationBlock = (params: {
|
||||
kind: MutationKind;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
startedAt: number;
|
||||
}): MutationBlockState => {
|
||||
return {
|
||||
kind: params.kind,
|
||||
agentId: params.agentId,
|
||||
agentName: params.agentName,
|
||||
phase: "queued",
|
||||
startedAt: params.startedAt,
|
||||
sawDisconnect: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildMutatingMutationBlock = (block: MutationBlockState): MutationBlockState => {
|
||||
return {
|
||||
...block,
|
||||
phase: "mutating",
|
||||
};
|
||||
};
|
||||
|
||||
export type MutationPostRunIntent =
|
||||
| { kind: "clear" }
|
||||
| { kind: "awaiting-restart"; patch: { phase: "awaiting-restart"; sawDisconnect: boolean } };
|
||||
|
||||
export const resolveMutationPostRunIntent = (params: {
|
||||
disposition: "completed" | "awaiting-restart";
|
||||
}): MutationPostRunIntent => {
|
||||
if (params.disposition === "awaiting-restart") {
|
||||
return {
|
||||
kind: "awaiting-restart",
|
||||
patch: {
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { kind: "clear" };
|
||||
};
|
||||
|
||||
export type MutationSideEffectCommand =
|
||||
| { kind: "reload-agents" }
|
||||
| { kind: "clear-mutation-block" }
|
||||
| { kind: "set-mobile-pane"; pane: "chat" }
|
||||
| { kind: "patch-mutation-block"; patch: { phase: "awaiting-restart"; sawDisconnect: boolean } };
|
||||
|
||||
export const buildMutationSideEffectCommands = (params: {
|
||||
disposition: "completed" | "awaiting-restart";
|
||||
}): MutationSideEffectCommand[] => {
|
||||
const postRunIntent = resolveMutationPostRunIntent({
|
||||
disposition: params.disposition,
|
||||
});
|
||||
if (postRunIntent.kind === "clear") {
|
||||
return [
|
||||
{ kind: "reload-agents" },
|
||||
{ kind: "clear-mutation-block" },
|
||||
{ kind: "set-mobile-pane", pane: "chat" },
|
||||
];
|
||||
}
|
||||
return [{ kind: "patch-mutation-block", patch: postRunIntent.patch }];
|
||||
};
|
||||
|
||||
export type MutationTimeoutIntent =
|
||||
| { kind: "none" }
|
||||
| { kind: "timeout"; reason: "create-timeout" | "rename-timeout" | "delete-timeout" };
|
||||
|
||||
const resolveTimeoutReason = (
|
||||
kind: MutationKind
|
||||
): "create-timeout" | "rename-timeout" | "delete-timeout" => {
|
||||
if (kind === "create-agent") {
|
||||
return "create-timeout";
|
||||
}
|
||||
if (kind === "rename-agent") {
|
||||
return "rename-timeout";
|
||||
}
|
||||
return "delete-timeout";
|
||||
};
|
||||
|
||||
export const resolveMutationTimeoutIntent = (params: {
|
||||
block: MutationBlockState | null;
|
||||
nowMs: number;
|
||||
maxWaitMs: number;
|
||||
}): MutationTimeoutIntent => {
|
||||
if (!params.block) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
const elapsed = params.nowMs - params.block.startedAt;
|
||||
if (elapsed < params.maxWaitMs) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
return {
|
||||
kind: "timeout",
|
||||
reason: resolveTimeoutReason(params.block.kind),
|
||||
};
|
||||
};
|
||||
|
||||
export type MutationWorkflowKind = "rename-agent" | "delete-agent";
|
||||
|
||||
export type MutationWorkflowResult = {
|
||||
disposition: "completed" | "awaiting-restart";
|
||||
};
|
||||
|
||||
export type AwaitingRestartPatch = {
|
||||
phase: "awaiting-restart";
|
||||
sawDisconnect: boolean;
|
||||
};
|
||||
|
||||
export type MutationWorkflowPostRunEffects = {
|
||||
shouldReloadAgents: boolean;
|
||||
shouldClearBlock: boolean;
|
||||
awaitingRestartPatch: AwaitingRestartPatch | null;
|
||||
};
|
||||
|
||||
export type MutationWorkflowDeps = {
|
||||
executeMutation: () => Promise<void>;
|
||||
shouldAwaitRemoteRestart: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
export type AgentConfigMutationLifecycleKind = MutationWorkflowKind;
|
||||
|
||||
export type AgentConfigMutationLifecycleDeps = {
|
||||
enqueueConfigMutation: (params: {
|
||||
kind: ConfigMutationKind;
|
||||
label: string;
|
||||
run: () => Promise<void>;
|
||||
requiresIdleAgents?: boolean;
|
||||
}) => Promise<void>;
|
||||
setQueuedBlock: () => void;
|
||||
setMutatingBlock: () => void;
|
||||
patchBlockAwaitingRestart: (patch: { phase: "awaiting-restart"; sawDisconnect: boolean }) => void;
|
||||
clearBlock: () => void;
|
||||
executeMutation: () => Promise<void>;
|
||||
shouldAwaitRemoteRestart: () => Promise<boolean>;
|
||||
reloadAgents: () => Promise<void>;
|
||||
setMobilePaneChat: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
export type CreateAgentBlockState = {
|
||||
agentName: string;
|
||||
phase: "queued" | "creating";
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export type CreateAgentLifecycleCompletion = {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
};
|
||||
|
||||
export type CreateAgentMutationLifecycleDeps = {
|
||||
enqueueConfigMutation: (params: {
|
||||
kind: ConfigMutationKind;
|
||||
label: string;
|
||||
run: () => Promise<void>;
|
||||
requiresIdleAgents?: boolean;
|
||||
}) => Promise<void>;
|
||||
createAgent: (name: string, avatarSeed: string | null) => Promise<{ id: string }>;
|
||||
setQueuedBlock: (params: { agentName: string; startedAt: number }) => void;
|
||||
setCreatingBlock: (agentName: string) => void;
|
||||
onCompletion: (completion: CreateAgentLifecycleCompletion) => Promise<void> | void;
|
||||
setCreateAgentModalError: (message: string | null) => void;
|
||||
setCreateAgentBusy: (busy: boolean) => void;
|
||||
clearCreateBlock: () => void;
|
||||
onError: (message: string) => void;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export type MutationStatusBlock = {
|
||||
phase: "queued" | "mutating" | "awaiting-restart";
|
||||
sawDisconnect: boolean;
|
||||
};
|
||||
|
||||
type MutationFailureMessageByKind = Record<MutationWorkflowKind, string>;
|
||||
|
||||
const FALLBACK_MUTATION_FAILURE_MESSAGE: MutationFailureMessageByKind = {
|
||||
"rename-agent": "Failed to rename agent.",
|
||||
"delete-agent": "Failed to delete agent.",
|
||||
};
|
||||
|
||||
const assertMutationKind = (kind: string): MutationWorkflowKind => {
|
||||
if (kind === "rename-agent" || kind === "delete-agent") {
|
||||
return kind;
|
||||
}
|
||||
throw new Error(`Unknown config mutation kind: ${kind}`);
|
||||
};
|
||||
|
||||
export const runConfigMutationWorkflow = async (
|
||||
params: { kind: MutationWorkflowKind; isLocalGateway: boolean },
|
||||
deps: MutationWorkflowDeps
|
||||
): Promise<MutationWorkflowResult> => {
|
||||
assertMutationKind(params.kind);
|
||||
await deps.executeMutation();
|
||||
if (params.isLocalGateway) {
|
||||
return { disposition: "completed" };
|
||||
}
|
||||
const shouldAwaitRestart = await deps.shouldAwaitRemoteRestart();
|
||||
return {
|
||||
disposition: shouldAwaitRestart ? "awaiting-restart" : "completed",
|
||||
};
|
||||
};
|
||||
|
||||
export const runAgentConfigMutationLifecycle = async (params: {
|
||||
kind: AgentConfigMutationLifecycleKind;
|
||||
label: string;
|
||||
isLocalGateway: boolean;
|
||||
deps: AgentConfigMutationLifecycleDeps;
|
||||
}): Promise<boolean> => {
|
||||
params.deps.setQueuedBlock();
|
||||
try {
|
||||
await params.deps.enqueueConfigMutation({
|
||||
kind: params.kind,
|
||||
label: params.label,
|
||||
run: async () => {
|
||||
params.deps.setMutatingBlock();
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: params.kind, isLocalGateway: params.isLocalGateway },
|
||||
{
|
||||
executeMutation: params.deps.executeMutation,
|
||||
shouldAwaitRemoteRestart: params.deps.shouldAwaitRemoteRestart,
|
||||
}
|
||||
);
|
||||
const commands = buildMutationSideEffectCommands({
|
||||
disposition: result.disposition,
|
||||
});
|
||||
for (const command of commands) {
|
||||
if (command.kind === "reload-agents") {
|
||||
await params.deps.reloadAgents();
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "clear-mutation-block") {
|
||||
params.deps.clearBlock();
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-mobile-pane") {
|
||||
params.deps.setMobilePaneChat();
|
||||
continue;
|
||||
}
|
||||
params.deps.patchBlockAwaitingRestart(command.patch);
|
||||
}
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
params.deps.clearBlock();
|
||||
params.deps.onError(
|
||||
buildConfigMutationFailureMessage({
|
||||
kind: params.kind,
|
||||
error,
|
||||
})
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const runCreateAgentMutationLifecycle = async (
|
||||
params: {
|
||||
payload: AgentCreateModalSubmitPayload;
|
||||
status: "connected" | "connecting" | "disconnected";
|
||||
hasCreateBlock: boolean;
|
||||
hasRenameBlock: boolean;
|
||||
hasDeleteBlock: boolean;
|
||||
createAgentBusy: boolean;
|
||||
},
|
||||
deps: CreateAgentMutationLifecycleDeps
|
||||
): Promise<boolean> => {
|
||||
if (params.createAgentBusy) return false;
|
||||
const guard = resolveMutationStartGuard({
|
||||
status: params.status,
|
||||
hasCreateBlock: params.hasCreateBlock,
|
||||
hasRenameBlock: params.hasRenameBlock,
|
||||
hasDeleteBlock: params.hasDeleteBlock,
|
||||
});
|
||||
if (guard.kind === "deny") {
|
||||
if (guard.reason === "not-connected") {
|
||||
deps.setCreateAgentModalError("Connect to gateway before creating an agent.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = params.payload.name.trim();
|
||||
if (!name) {
|
||||
deps.setCreateAgentModalError("Agent name is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
deps.setCreateAgentBusy(true);
|
||||
deps.setCreateAgentModalError(null);
|
||||
const startedAt = (deps.now ?? Date.now)();
|
||||
deps.setQueuedBlock({ agentName: name, startedAt });
|
||||
const avatarSeed = params.payload.avatarSeed?.trim() ?? null;
|
||||
try {
|
||||
const queuedMutation = deps.enqueueConfigMutation({
|
||||
kind: "create-agent",
|
||||
label: `Create ${name}`,
|
||||
run: async () => {
|
||||
deps.setCreatingBlock(name);
|
||||
const created = await deps.createAgent(name, avatarSeed);
|
||||
await deps.onCompletion({
|
||||
agentId: created.id,
|
||||
agentName: name,
|
||||
});
|
||||
},
|
||||
});
|
||||
await queuedMutation;
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create agent.";
|
||||
deps.clearCreateBlock();
|
||||
deps.setCreateAgentModalError(message);
|
||||
deps.onError(message);
|
||||
return false;
|
||||
} finally {
|
||||
deps.setCreateAgentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
export const isCreateBlockTimedOut = (params: {
|
||||
block: CreateAgentBlockState | null;
|
||||
nowMs: number;
|
||||
maxWaitMs: number;
|
||||
}): boolean => {
|
||||
if (!params.block || params.block.phase === "queued") {
|
||||
return false;
|
||||
}
|
||||
const timeoutIntent = resolveMutationTimeoutIntent({
|
||||
block: {
|
||||
kind: "create-agent",
|
||||
agentId: "",
|
||||
agentName: params.block.agentName,
|
||||
phase: "mutating",
|
||||
startedAt: params.block.startedAt,
|
||||
sawDisconnect: false,
|
||||
},
|
||||
nowMs: params.nowMs,
|
||||
maxWaitMs: params.maxWaitMs,
|
||||
});
|
||||
return timeoutIntent.kind === "timeout" && timeoutIntent.reason === "create-timeout";
|
||||
};
|
||||
|
||||
export const buildConfigMutationFailureMessage = (params: {
|
||||
kind: MutationWorkflowKind;
|
||||
error: unknown;
|
||||
}): string => {
|
||||
const fallback = FALLBACK_MUTATION_FAILURE_MESSAGE[params.kind];
|
||||
if (params.error instanceof Error) {
|
||||
return params.error.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const resolveConfigMutationStatusLine = (params: {
|
||||
block: MutationStatusBlock | null;
|
||||
status: GatewayStatus;
|
||||
mutatingLabel?: string;
|
||||
}): string | null => {
|
||||
const { block, status } = params;
|
||||
if (!block) return null;
|
||||
if (block.phase === "queued") {
|
||||
return "Waiting for active runs to finish";
|
||||
}
|
||||
if (block.phase === "mutating") {
|
||||
return params.mutatingLabel ?? "Submitting config change";
|
||||
}
|
||||
if (!block.sawDisconnect) {
|
||||
return "Waiting for gateway to restart";
|
||||
}
|
||||
return status === "connected"
|
||||
? "Gateway is back online, syncing agents"
|
||||
: "Gateway restart in progress";
|
||||
};
|
||||
|
||||
export const buildAwaitingRestartPatch = (): AwaitingRestartPatch => {
|
||||
return {
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveConfigMutationPostRunEffects = (
|
||||
result: MutationWorkflowResult
|
||||
): MutationWorkflowPostRunEffects => {
|
||||
const commands = buildMutationSideEffectCommands({
|
||||
disposition: result.disposition,
|
||||
});
|
||||
let shouldReloadAgents = false;
|
||||
let shouldClearBlock = false;
|
||||
let awaitingRestartPatch: AwaitingRestartPatch | null = null;
|
||||
for (const command of commands) {
|
||||
if (command.kind === "reload-agents") {
|
||||
shouldReloadAgents = true;
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "clear-mutation-block") {
|
||||
shouldClearBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "patch-mutation-block") {
|
||||
awaitingRestartPatch = command.patch;
|
||||
}
|
||||
}
|
||||
return {
|
||||
shouldReloadAgents,
|
||||
shouldClearBlock,
|
||||
awaitingRestartPatch,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
export type RuntimeSyncStatus = "disconnected" | "connecting" | "connected";
|
||||
|
||||
export const RUNTIME_SYNC_RECONCILE_INTERVAL_MS = 3000;
|
||||
export const RUNTIME_SYNC_FOCUSED_HISTORY_INTERVAL_MS = 4500;
|
||||
export const RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT = 200;
|
||||
export const RUNTIME_SYNC_MAX_HISTORY_LIMIT = 5000;
|
||||
|
||||
const RUNTIME_SYNC_MIN_LOAD_MORE_HISTORY_LIMIT = 400;
|
||||
|
||||
type RuntimeSyncHistoryBootstrapAgent = Pick<
|
||||
AgentState,
|
||||
"agentId" | "sessionCreated" | "historyLoadedAt"
|
||||
>;
|
||||
|
||||
type RuntimeSyncFocusedPollingAgent = Pick<AgentState, "agentId" | "status">;
|
||||
|
||||
export type RuntimeSyncReconcilePollingIntent =
|
||||
| { kind: "start"; intervalMs: number; runImmediately: true }
|
||||
| { kind: "stop"; reason: "not-connected" };
|
||||
|
||||
export type RuntimeSyncFocusedHistoryPollingIntent =
|
||||
| { kind: "start"; agentId: string; intervalMs: number; runImmediately: true }
|
||||
| {
|
||||
kind: "stop";
|
||||
reason: "not-connected" | "missing-focused-agent" | "focused-not-running";
|
||||
};
|
||||
|
||||
export const resolveRuntimeSyncReconcilePollingIntent = (params: {
|
||||
status: RuntimeSyncStatus;
|
||||
}): RuntimeSyncReconcilePollingIntent => {
|
||||
if (params.status !== "connected") {
|
||||
return { kind: "stop", reason: "not-connected" };
|
||||
}
|
||||
return {
|
||||
kind: "start",
|
||||
intervalMs: RUNTIME_SYNC_RECONCILE_INTERVAL_MS,
|
||||
runImmediately: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveRuntimeSyncBootstrapHistoryAgentIds = (params: {
|
||||
status: RuntimeSyncStatus;
|
||||
agents: RuntimeSyncHistoryBootstrapAgent[];
|
||||
}): string[] => {
|
||||
if (params.status !== "connected") return [];
|
||||
const ids: string[] = [];
|
||||
for (const agent of params.agents) {
|
||||
if (!agent.sessionCreated) continue;
|
||||
if (agent.historyLoadedAt !== null) continue;
|
||||
const agentId = agent.agentId.trim();
|
||||
if (!agentId) continue;
|
||||
ids.push(agentId);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
export const resolveRuntimeSyncFocusedHistoryPollingIntent = (params: {
|
||||
status: RuntimeSyncStatus;
|
||||
focusedAgentId: string | null;
|
||||
focusedAgentRunning: boolean;
|
||||
}): RuntimeSyncFocusedHistoryPollingIntent => {
|
||||
if (params.status !== "connected") {
|
||||
return { kind: "stop", reason: "not-connected" };
|
||||
}
|
||||
const focusedAgentId = params.focusedAgentId?.trim() ?? "";
|
||||
if (!focusedAgentId) {
|
||||
return { kind: "stop", reason: "missing-focused-agent" };
|
||||
}
|
||||
if (!params.focusedAgentRunning) {
|
||||
return { kind: "stop", reason: "focused-not-running" };
|
||||
}
|
||||
return {
|
||||
kind: "start",
|
||||
agentId: focusedAgentId,
|
||||
intervalMs: RUNTIME_SYNC_FOCUSED_HISTORY_INTERVAL_MS,
|
||||
runImmediately: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldRuntimeSyncContinueFocusedHistoryPolling = (params: {
|
||||
agentId: string;
|
||||
agents: RuntimeSyncFocusedPollingAgent[];
|
||||
}): boolean => {
|
||||
const target = params.agentId.trim();
|
||||
if (!target) return false;
|
||||
const agent = params.agents.find((entry) => entry.agentId === target) ?? null;
|
||||
if (!agent) return false;
|
||||
return agent.status === "running";
|
||||
};
|
||||
|
||||
export const resolveRuntimeSyncLoadMoreHistoryLimit = (params: {
|
||||
currentLimit: number | null;
|
||||
defaultLimit: number;
|
||||
maxLimit: number;
|
||||
}): number => {
|
||||
const currentLimit =
|
||||
typeof params.currentLimit === "number" && Number.isFinite(params.currentLimit)
|
||||
? params.currentLimit
|
||||
: params.defaultLimit;
|
||||
const nextLimit = Math.max(RUNTIME_SYNC_MIN_LOAD_MORE_HISTORY_LIMIT, currentLimit * 2);
|
||||
return Math.min(params.maxLimit, nextLimit);
|
||||
};
|
||||
|
||||
export const resolveRuntimeSyncGapRecoveryIntent = () => {
|
||||
return {
|
||||
refreshSummarySnapshot: true,
|
||||
reconcileRunningAgents: true,
|
||||
} as const;
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
export type SettingsRouteTab =
|
||||
| "personality"
|
||||
| "capabilities"
|
||||
| "skills"
|
||||
| "system"
|
||||
| "automations"
|
||||
| "advanced";
|
||||
|
||||
export type InspectSidebarState =
|
||||
| { agentId: string; tab: SettingsRouteTab }
|
||||
| null;
|
||||
|
||||
export type SettingsRouteNavCommand =
|
||||
| { kind: "select-agent"; agentId: string | null }
|
||||
| { kind: "set-inspect-sidebar"; value: InspectSidebarState }
|
||||
| { kind: "set-mobile-pane-chat" }
|
||||
| { kind: "set-personality-dirty"; value: boolean }
|
||||
| { kind: "flush-pending-draft"; agentId: string | null }
|
||||
| { kind: "push"; href: string }
|
||||
| { kind: "replace"; href: string };
|
||||
|
||||
export const SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM = "settingsAgentId";
|
||||
|
||||
export const parseSettingsRouteAgentIdFromPathname = (pathname: string): string | null => {
|
||||
const match = pathname.match(/^\/agents\/([^/]+)\/settings\/?$/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const decoded = decodeURIComponent(match[1] ?? "");
|
||||
const trimmed = decoded.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
} catch {
|
||||
const raw = (match[1] ?? "").trim();
|
||||
return raw ? raw : null;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseSettingsRouteAgentIdFromQueryParam = (value: string | null | undefined): string | null => {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const decoded = decodeURIComponent(trimmed).trim();
|
||||
return decoded ? decoded : null;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildSettingsRouteHref = (agentId: string): string => {
|
||||
const resolved = agentId.trim();
|
||||
if (!resolved) {
|
||||
throw new Error("Cannot build settings route href: agent id is empty.");
|
||||
}
|
||||
return `/agents?${SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM}=${encodeURIComponent(resolved)}`;
|
||||
};
|
||||
|
||||
export const shouldConfirmDiscardPersonalityChanges = (params: {
|
||||
settingsRouteActive: boolean;
|
||||
activeTab: SettingsRouteTab;
|
||||
personalityHasUnsavedChanges: boolean;
|
||||
}): boolean => {
|
||||
if (!params.settingsRouteActive) return false;
|
||||
if (params.activeTab !== "personality") return false;
|
||||
return params.personalityHasUnsavedChanges;
|
||||
};
|
||||
|
||||
export const planBackToChatCommands = (input: {
|
||||
settingsRouteActive: boolean;
|
||||
activeTab: SettingsRouteTab;
|
||||
personalityHasUnsavedChanges: boolean;
|
||||
discardConfirmed: boolean;
|
||||
}): SettingsRouteNavCommand[] => {
|
||||
if (
|
||||
shouldConfirmDiscardPersonalityChanges({
|
||||
settingsRouteActive: input.settingsRouteActive,
|
||||
activeTab: input.activeTab,
|
||||
personalityHasUnsavedChanges: input.personalityHasUnsavedChanges,
|
||||
}) &&
|
||||
!input.discardConfirmed
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ kind: "set-personality-dirty", value: false },
|
||||
{ kind: "push", href: "/" },
|
||||
];
|
||||
};
|
||||
|
||||
export const planSettingsTabChangeCommands = (input: {
|
||||
nextTab: SettingsRouteTab;
|
||||
currentInspectSidebar: InspectSidebarState;
|
||||
settingsRouteAgentId: string | null;
|
||||
settingsRouteActive: boolean;
|
||||
personalityHasUnsavedChanges: boolean;
|
||||
discardConfirmed: boolean;
|
||||
}): SettingsRouteNavCommand[] => {
|
||||
const resolvedAgentId =
|
||||
(input.currentInspectSidebar?.agentId ?? input.settingsRouteAgentId ?? "").trim();
|
||||
if (!resolvedAgentId) return [];
|
||||
|
||||
const currentTab = input.currentInspectSidebar?.tab ?? "personality";
|
||||
if (currentTab === input.nextTab) return [];
|
||||
|
||||
const requiresDiscardConfirmation =
|
||||
currentTab === "personality" &&
|
||||
input.nextTab !== "personality" &&
|
||||
shouldConfirmDiscardPersonalityChanges({
|
||||
settingsRouteActive: input.settingsRouteActive,
|
||||
activeTab: currentTab,
|
||||
personalityHasUnsavedChanges: input.personalityHasUnsavedChanges,
|
||||
});
|
||||
|
||||
if (requiresDiscardConfirmation && !input.discardConfirmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const commands: SettingsRouteNavCommand[] = [];
|
||||
if (requiresDiscardConfirmation) {
|
||||
commands.push({ kind: "set-personality-dirty", value: false });
|
||||
}
|
||||
commands.push({
|
||||
kind: "set-inspect-sidebar",
|
||||
value: { agentId: resolvedAgentId, tab: input.nextTab },
|
||||
});
|
||||
return commands;
|
||||
};
|
||||
|
||||
export const planOpenSettingsRouteCommands = (input: {
|
||||
agentId: string;
|
||||
currentInspectSidebar: InspectSidebarState;
|
||||
focusedAgentId: string | null;
|
||||
}): SettingsRouteNavCommand[] => {
|
||||
const resolvedAgentId = input.agentId.trim();
|
||||
if (!resolvedAgentId) return [];
|
||||
|
||||
const commands: SettingsRouteNavCommand[] = [
|
||||
{
|
||||
kind: "flush-pending-draft",
|
||||
agentId: input.focusedAgentId,
|
||||
},
|
||||
{
|
||||
kind: "select-agent",
|
||||
agentId: resolvedAgentId,
|
||||
},
|
||||
];
|
||||
|
||||
if (input.currentInspectSidebar?.agentId !== resolvedAgentId) {
|
||||
commands.push({
|
||||
kind: "set-inspect-sidebar",
|
||||
value: {
|
||||
agentId: resolvedAgentId,
|
||||
tab: input.currentInspectSidebar?.tab ?? "personality",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
commands.push(
|
||||
{ kind: "set-mobile-pane-chat" },
|
||||
{ kind: "push", href: buildSettingsRouteHref(resolvedAgentId) }
|
||||
);
|
||||
|
||||
return commands;
|
||||
};
|
||||
|
||||
export const planFleetSelectCommands = (input: {
|
||||
agentId: string;
|
||||
currentInspectSidebar: InspectSidebarState;
|
||||
focusedAgentId: string | null;
|
||||
}): SettingsRouteNavCommand[] => {
|
||||
const resolvedAgentId = input.agentId.trim();
|
||||
if (!resolvedAgentId) return [];
|
||||
|
||||
const commands: SettingsRouteNavCommand[] = [
|
||||
{
|
||||
kind: "flush-pending-draft",
|
||||
agentId: input.focusedAgentId,
|
||||
},
|
||||
{
|
||||
kind: "select-agent",
|
||||
agentId: resolvedAgentId,
|
||||
},
|
||||
];
|
||||
|
||||
if (input.currentInspectSidebar) {
|
||||
commands.push({
|
||||
kind: "set-inspect-sidebar",
|
||||
value: {
|
||||
...input.currentInspectSidebar,
|
||||
agentId: resolvedAgentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
commands.push({ kind: "set-mobile-pane-chat" });
|
||||
return commands;
|
||||
};
|
||||
|
||||
export const planSettingsRouteSyncCommands = (input: {
|
||||
settingsRouteActive: boolean;
|
||||
settingsRouteAgentId: string | null;
|
||||
status: "disconnected" | "connecting" | "connected";
|
||||
agentsLoadedOnce: boolean;
|
||||
selectedAgentId: string | null;
|
||||
hasRouteAgent: boolean;
|
||||
currentInspectSidebar: InspectSidebarState;
|
||||
}): SettingsRouteNavCommand[] => {
|
||||
const commands: SettingsRouteNavCommand[] = [];
|
||||
const routeAgentId = (input.settingsRouteAgentId ?? "").trim();
|
||||
|
||||
if (routeAgentId && input.hasRouteAgent) {
|
||||
if (input.currentInspectSidebar?.agentId !== routeAgentId) {
|
||||
commands.push({
|
||||
kind: "set-inspect-sidebar",
|
||||
value: {
|
||||
agentId: routeAgentId,
|
||||
tab: input.currentInspectSidebar?.tab ?? "personality",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.selectedAgentId !== routeAgentId) {
|
||||
commands.push({ kind: "select-agent", agentId: routeAgentId });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.settingsRouteActive &&
|
||||
routeAgentId &&
|
||||
input.status === "connected" &&
|
||||
input.agentsLoadedOnce &&
|
||||
!input.hasRouteAgent
|
||||
) {
|
||||
commands.push({ kind: "replace", href: "/" });
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
|
||||
export const planNonRouteSelectionSyncCommands = (input: {
|
||||
settingsRouteActive: boolean;
|
||||
selectedAgentId: string | null;
|
||||
focusedAgentId: string | null;
|
||||
hasSelectedAgentInAgents: boolean;
|
||||
currentInspectSidebar: InspectSidebarState;
|
||||
hasInspectSidebarAgent: boolean;
|
||||
}): SettingsRouteNavCommand[] => {
|
||||
if (input.settingsRouteActive) return [];
|
||||
|
||||
const commands: SettingsRouteNavCommand[] = [];
|
||||
const selectedAgentId = input.selectedAgentId?.trim() ?? "";
|
||||
|
||||
if (input.currentInspectSidebar) {
|
||||
if (!selectedAgentId) {
|
||||
commands.push({ kind: "set-inspect-sidebar", value: null });
|
||||
} else if (input.currentInspectSidebar.agentId !== selectedAgentId) {
|
||||
commands.push({
|
||||
kind: "set-inspect-sidebar",
|
||||
value: {
|
||||
...input.currentInspectSidebar,
|
||||
agentId: selectedAgentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.currentInspectSidebar?.agentId && !input.hasInspectSidebarAgent) {
|
||||
commands.push({ kind: "set-inspect-sidebar", value: null });
|
||||
}
|
||||
|
||||
if (selectedAgentId && !input.hasSelectedAgentInAgents) {
|
||||
commands.push({ kind: "select-agent", agentId: null });
|
||||
}
|
||||
|
||||
const nextSelectedAgentId = input.focusedAgentId ?? null;
|
||||
if (input.selectedAgentId !== nextSelectedAgentId) {
|
||||
commands.push({ kind: "select-agent", agentId: nextSelectedAgentId });
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
import {
|
||||
buildLatestUpdatePatch,
|
||||
resolveLatestUpdateIntent,
|
||||
} from "@/features/agents/operations/latestUpdateWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { extractText, isHeartbeatPrompt, stripUiMetadata } from "@/lib/text/message-extract";
|
||||
|
||||
type ChatHistoryMessage = Record<string, unknown>;
|
||||
|
||||
type ChatHistoryResult = {
|
||||
messages?: ChatHistoryMessage[];
|
||||
};
|
||||
|
||||
type SessionsListEntry = {
|
||||
key?: string;
|
||||
updatedAt?: number | null;
|
||||
origin?: { label?: string | null } | null;
|
||||
};
|
||||
|
||||
type SessionsListResult = {
|
||||
sessions?: SessionsListEntry[];
|
||||
};
|
||||
|
||||
const findLatestHeartbeatResponse = (messages: ChatHistoryMessage[]) => {
|
||||
let awaitingHeartbeatReply = false;
|
||||
let latestResponse: string | null = null;
|
||||
for (const message of messages) {
|
||||
const role = typeof message.role === "string" ? message.role : "";
|
||||
if (role === "user") {
|
||||
const text = stripUiMetadata(extractText(message) ?? "").trim();
|
||||
awaitingHeartbeatReply = isHeartbeatPrompt(text);
|
||||
continue;
|
||||
}
|
||||
if (role === "assistant" && awaitingHeartbeatReply) {
|
||||
const text = stripUiMetadata(extractText(message) ?? "").trim();
|
||||
if (text) {
|
||||
latestResponse = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return latestResponse;
|
||||
};
|
||||
|
||||
export type SpecialLatestUpdateDeps = {
|
||||
callGateway: (method: string, params: unknown) => Promise<unknown>;
|
||||
listCronJobs: () => Promise<{ jobs: CronJobSummary[] }>;
|
||||
resolveCronJobForAgent: (jobs: CronJobSummary[], agentId: string) => CronJobSummary | null;
|
||||
formatCronJobDisplay: (job: CronJobSummary) => string;
|
||||
dispatchUpdateAgent: (
|
||||
agentId: string,
|
||||
patch: { latestOverride: string | null; latestOverrideKind: "heartbeat" | "cron" | null }
|
||||
) => void;
|
||||
isDisconnectLikeError: (err: unknown) => boolean;
|
||||
logError: (message: string) => void;
|
||||
};
|
||||
|
||||
export type SpecialLatestUpdateOperation = {
|
||||
update: (agentId: string, agent: AgentState, message: string) => Promise<void>;
|
||||
refreshHeartbeat: (agents: AgentState[]) => void;
|
||||
clearInFlight: (agentId: string) => void;
|
||||
};
|
||||
|
||||
export function createSpecialLatestUpdateOperation(
|
||||
deps: SpecialLatestUpdateDeps
|
||||
): SpecialLatestUpdateOperation {
|
||||
const inFlight = new Set<string>();
|
||||
|
||||
const update: SpecialLatestUpdateOperation["update"] = async (agentId, agent, message) => {
|
||||
const intent = resolveLatestUpdateIntent({
|
||||
message,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
hasExistingOverride: Boolean(agent.latestOverride || agent.latestOverrideKind),
|
||||
});
|
||||
if (intent.kind === "noop") return;
|
||||
if (intent.kind === "reset") {
|
||||
deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch(""));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = agentId;
|
||||
if (inFlight.has(key)) return;
|
||||
inFlight.add(key);
|
||||
|
||||
try {
|
||||
if (intent.kind === "fetch-heartbeat") {
|
||||
const result = (await deps.callGateway("sessions.list", {
|
||||
agentId: intent.agentId,
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
limit: intent.sessionLimit,
|
||||
})) as SessionsListResult;
|
||||
|
||||
const entries = Array.isArray(result.sessions) ? result.sessions : [];
|
||||
const heartbeatSessions = entries.filter((entry) => {
|
||||
const label = entry.origin?.label;
|
||||
return typeof label === "string" && label.toLowerCase() === "heartbeat";
|
||||
});
|
||||
const candidates = heartbeatSessions.length > 0 ? heartbeatSessions : entries;
|
||||
const sorted = [...candidates].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
const sessionKey = sorted[0]?.key;
|
||||
if (!sessionKey) {
|
||||
deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch(""));
|
||||
return;
|
||||
}
|
||||
|
||||
const history = (await deps.callGateway("chat.history", {
|
||||
sessionKey,
|
||||
limit: intent.historyLimit,
|
||||
})) as ChatHistoryResult;
|
||||
const messages = Array.isArray(history.messages) ? history.messages : [];
|
||||
const content = findLatestHeartbeatResponse(messages) ?? "";
|
||||
deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch(content, "heartbeat"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === "fetch-cron") {
|
||||
const cronResult = await deps.listCronJobs();
|
||||
const job = deps.resolveCronJobForAgent(cronResult.jobs, intent.agentId);
|
||||
const content = job ? deps.formatCronJobDisplay(job) : "";
|
||||
deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch(content, "cron"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!deps.isDisconnectLikeError(err)) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to load latest cron/heartbeat update.";
|
||||
deps.logError(message);
|
||||
}
|
||||
} finally {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshHeartbeat: SpecialLatestUpdateOperation["refreshHeartbeat"] = (agents) => {
|
||||
for (const agent of agents) {
|
||||
void update(agent.agentId, agent, "heartbeat");
|
||||
}
|
||||
};
|
||||
|
||||
const clearInFlight: SpecialLatestUpdateOperation["clearInFlight"] = (agentId) => {
|
||||
inFlight.delete(agentId);
|
||||
};
|
||||
|
||||
return { update, refreshHeartbeat, clearInFlight };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
|
||||
import {
|
||||
planBootstrapSelection,
|
||||
planFocusedFilterPatch,
|
||||
planFocusedPreferenceRestore,
|
||||
planFocusedSelectionPatch,
|
||||
} from "@/features/agents/operations/studioBootstrapWorkflow";
|
||||
import type { AgentState, AgentStoreSeed, FocusFilter } from "@/features/agents/state/store";
|
||||
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||
import type {
|
||||
StudioSettings,
|
||||
StudioSettingsPatch,
|
||||
StudioSettingsPublic,
|
||||
} from "@/lib/studio/settings";
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type StudioBootstrapLoadCommand =
|
||||
| { kind: "set-gateway-config-snapshot"; snapshot: GatewayModelPolicySnapshot }
|
||||
| { kind: "hydrate-agents"; seeds: AgentStoreSeed[]; initialSelectedAgentId: string | undefined }
|
||||
| { kind: "mark-session-created"; agentId: string; sessionSettingsSynced: boolean }
|
||||
| { kind: "apply-summary-patch"; agentId: string; patch: Partial<AgentState> }
|
||||
| { kind: "set-error"; message: string };
|
||||
|
||||
export async function runStudioBootstrapLoadOperation(params: {
|
||||
client: GatewayClientLike;
|
||||
gatewayUrl: string;
|
||||
cachedConfigSnapshot: GatewayModelPolicySnapshot | null;
|
||||
loadStudioSettings: () => Promise<StudioSettings | StudioSettingsPublic | null>;
|
||||
isDisconnectLikeError: (err: unknown) => boolean;
|
||||
preferredSelectedAgentId: string | null;
|
||||
hasCurrentSelection: boolean;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
}): Promise<StudioBootstrapLoadCommand[]> {
|
||||
try {
|
||||
const result = await hydrateAgentFleetFromGateway({
|
||||
client: params.client,
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
cachedConfigSnapshot: params.cachedConfigSnapshot,
|
||||
loadStudioSettings: params.loadStudioSettings,
|
||||
isDisconnectLikeError: params.isDisconnectLikeError,
|
||||
logError: params.logError,
|
||||
});
|
||||
|
||||
const selectionIntent = planBootstrapSelection({
|
||||
hasCurrentSelection: params.hasCurrentSelection,
|
||||
preferredSelectedAgentId: params.preferredSelectedAgentId,
|
||||
availableAgentIds: result.seeds.map((seed) => seed.agentId),
|
||||
suggestedSelectedAgentId: result.suggestedSelectedAgentId,
|
||||
});
|
||||
|
||||
const commands: StudioBootstrapLoadCommand[] = [];
|
||||
if (!params.cachedConfigSnapshot && result.configSnapshot) {
|
||||
commands.push({
|
||||
kind: "set-gateway-config-snapshot",
|
||||
snapshot: result.configSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
commands.push({
|
||||
kind: "hydrate-agents",
|
||||
seeds: result.seeds,
|
||||
initialSelectedAgentId: selectionIntent.initialSelectedAgentId,
|
||||
});
|
||||
|
||||
const sessionSettingsSyncedAgentIds = new Set(result.sessionSettingsSyncedAgentIds);
|
||||
for (const agentId of result.sessionCreatedAgentIds) {
|
||||
commands.push({
|
||||
kind: "mark-session-created",
|
||||
agentId,
|
||||
sessionSettingsSynced: sessionSettingsSyncedAgentIds.has(agentId),
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of result.summaryPatches) {
|
||||
commands.push({
|
||||
kind: "apply-summary-patch",
|
||||
agentId: entry.agentId,
|
||||
patch: entry.patch,
|
||||
});
|
||||
}
|
||||
|
||||
return commands;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load agents.";
|
||||
return [{ kind: "set-error", message }];
|
||||
}
|
||||
}
|
||||
|
||||
export function executeStudioBootstrapLoadCommands(params: {
|
||||
commands: StudioBootstrapLoadCommand[];
|
||||
setGatewayConfigSnapshot: (snapshot: GatewayModelPolicySnapshot) => void;
|
||||
hydrateAgents: (agents: AgentStoreSeed[], selectedAgentId?: string) => void;
|
||||
dispatchUpdateAgent: (agentId: string, patch: Partial<AgentState>) => void;
|
||||
setError: (message: string) => void;
|
||||
}): void {
|
||||
for (const command of params.commands) {
|
||||
if (command.kind === "set-gateway-config-snapshot") {
|
||||
params.setGatewayConfigSnapshot(command.snapshot);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "hydrate-agents") {
|
||||
params.hydrateAgents(command.seeds, command.initialSelectedAgentId);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "mark-session-created") {
|
||||
params.dispatchUpdateAgent(command.agentId, {
|
||||
sessionCreated: true,
|
||||
sessionSettingsSynced: command.sessionSettingsSynced,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "apply-summary-patch") {
|
||||
params.dispatchUpdateAgent(command.agentId, command.patch);
|
||||
continue;
|
||||
}
|
||||
params.setError(command.message);
|
||||
}
|
||||
}
|
||||
|
||||
export type StudioFocusedPreferenceLoadCommand =
|
||||
| { kind: "set-focused-preferences-loaded"; value: boolean }
|
||||
| { kind: "set-preferred-selected-agent-id"; agentId: string | null }
|
||||
| { kind: "set-focus-filter"; filter: FocusFilter }
|
||||
| { kind: "log-error"; message: string; error: unknown };
|
||||
|
||||
export async function runStudioFocusedPreferenceLoadOperation(params: {
|
||||
gatewayUrl: string;
|
||||
loadStudioSettings: () => Promise<StudioSettings | StudioSettingsPublic | null>;
|
||||
isFocusFilterTouched: () => boolean;
|
||||
}): Promise<StudioFocusedPreferenceLoadCommand[]> {
|
||||
const key = params.gatewayUrl.trim();
|
||||
if (!key) {
|
||||
return [
|
||||
{ kind: "set-preferred-selected-agent-id", agentId: null },
|
||||
{ kind: "set-focused-preferences-loaded", value: true },
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await params.loadStudioSettings();
|
||||
if (!settings || params.isFocusFilterTouched()) {
|
||||
return [{ kind: "set-focused-preferences-loaded", value: true }];
|
||||
}
|
||||
|
||||
const restoreIntent = planFocusedPreferenceRestore({
|
||||
settings,
|
||||
gatewayKey: key,
|
||||
focusFilterTouched: false,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "set-preferred-selected-agent-id",
|
||||
agentId: restoreIntent.preferredSelectedAgentId,
|
||||
},
|
||||
{
|
||||
kind: "set-focus-filter",
|
||||
filter: restoreIntent.focusFilter,
|
||||
},
|
||||
{ kind: "set-focused-preferences-loaded", value: true },
|
||||
];
|
||||
} catch (error) {
|
||||
return [
|
||||
{
|
||||
kind: "log-error",
|
||||
message: "Failed to load focused preference.",
|
||||
error,
|
||||
},
|
||||
{ kind: "set-focused-preferences-loaded", value: true },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function executeStudioFocusedPreferenceLoadCommands(params: {
|
||||
commands: StudioFocusedPreferenceLoadCommand[];
|
||||
setFocusedPreferencesLoaded: (value: boolean) => void;
|
||||
setPreferredSelectedAgentId: (agentId: string | null) => void;
|
||||
setFocusFilter: (filter: FocusFilter) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}): void {
|
||||
for (const command of params.commands) {
|
||||
if (command.kind === "set-focused-preferences-loaded") {
|
||||
params.setFocusedPreferencesLoaded(command.value);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-preferred-selected-agent-id") {
|
||||
params.setPreferredSelectedAgentId(command.agentId);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-focus-filter") {
|
||||
params.setFocusFilter(command.filter);
|
||||
continue;
|
||||
}
|
||||
params.logError(command.message, command.error);
|
||||
}
|
||||
}
|
||||
|
||||
export type StudioFocusedPatchCommand = {
|
||||
kind: "schedule-settings-patch";
|
||||
patch: StudioSettingsPatch;
|
||||
debounceMs: number;
|
||||
};
|
||||
|
||||
export function runStudioFocusFilterPersistenceOperation(params: {
|
||||
gatewayUrl: string;
|
||||
focusFilterTouched: boolean;
|
||||
focusFilter: FocusFilter;
|
||||
}): StudioFocusedPatchCommand[] {
|
||||
const patchIntent = planFocusedFilterPatch({
|
||||
gatewayKey: params.gatewayUrl,
|
||||
focusFilterTouched: params.focusFilterTouched,
|
||||
focusFilter: params.focusFilter,
|
||||
});
|
||||
if (patchIntent.kind !== "patch") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: "schedule-settings-patch",
|
||||
patch: patchIntent.patch,
|
||||
debounceMs: patchIntent.debounceMs,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function runStudioFocusedSelectionPersistenceOperation(params: {
|
||||
gatewayUrl: string;
|
||||
status: "connected" | "connecting" | "disconnected";
|
||||
focusedPreferencesLoaded: boolean;
|
||||
agentsLoadedOnce: boolean;
|
||||
selectedAgentId: string | null;
|
||||
}): StudioFocusedPatchCommand[] {
|
||||
const patchIntent = planFocusedSelectionPatch({
|
||||
gatewayKey: params.gatewayUrl,
|
||||
status: params.status,
|
||||
focusedPreferencesLoaded: params.focusedPreferencesLoaded,
|
||||
agentsLoadedOnce: params.agentsLoadedOnce,
|
||||
selectedAgentId: params.selectedAgentId,
|
||||
});
|
||||
|
||||
if (patchIntent.kind !== "patch") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "schedule-settings-patch",
|
||||
patch: patchIntent.patch,
|
||||
debounceMs: patchIntent.debounceMs,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function executeStudioFocusedPatchCommands(params: {
|
||||
commands: StudioFocusedPatchCommand[];
|
||||
schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void;
|
||||
}): void {
|
||||
for (const command of params.commands) {
|
||||
params.schedulePatch(command.patch, command.debounceMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { FocusFilter } from "@/features/agents/state/store";
|
||||
import {
|
||||
resolveFocusedPreference,
|
||||
type StudioSettings,
|
||||
type StudioSettingsPublic,
|
||||
type StudioSettingsPatch,
|
||||
} from "@/lib/studio/settings";
|
||||
|
||||
const FOCUSED_PATCH_DEBOUNCE_MS = 300;
|
||||
|
||||
export type BootstrapSelectionIntent = {
|
||||
initialSelectedAgentId: string | undefined;
|
||||
};
|
||||
|
||||
export function planBootstrapSelection(params: {
|
||||
hasCurrentSelection: boolean;
|
||||
preferredSelectedAgentId: string | null;
|
||||
availableAgentIds: string[];
|
||||
suggestedSelectedAgentId: string | null;
|
||||
}): BootstrapSelectionIntent {
|
||||
if (params.hasCurrentSelection) {
|
||||
return { initialSelectedAgentId: undefined };
|
||||
}
|
||||
|
||||
const preferredSelectedAgentId = params.preferredSelectedAgentId?.trim() ?? "";
|
||||
if (
|
||||
preferredSelectedAgentId.length > 0 &&
|
||||
params.availableAgentIds.some((agentId) => agentId === preferredSelectedAgentId)
|
||||
) {
|
||||
return { initialSelectedAgentId: preferredSelectedAgentId };
|
||||
}
|
||||
|
||||
const suggestedSelectedAgentId = params.suggestedSelectedAgentId?.trim() ?? "";
|
||||
return {
|
||||
initialSelectedAgentId:
|
||||
suggestedSelectedAgentId.length > 0 ? suggestedSelectedAgentId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type FocusFilterPatchIntent =
|
||||
| {
|
||||
kind: "skip";
|
||||
reason: "missing-gateway-key" | "focus-filter-not-touched";
|
||||
}
|
||||
| {
|
||||
kind: "patch";
|
||||
patch: StudioSettingsPatch;
|
||||
debounceMs: number;
|
||||
};
|
||||
|
||||
export function planFocusedFilterPatch(params: {
|
||||
gatewayKey: string;
|
||||
focusFilterTouched: boolean;
|
||||
focusFilter: FocusFilter;
|
||||
}): FocusFilterPatchIntent {
|
||||
const gatewayKey = params.gatewayKey.trim();
|
||||
if (!gatewayKey) {
|
||||
return { kind: "skip", reason: "missing-gateway-key" };
|
||||
}
|
||||
if (!params.focusFilterTouched) {
|
||||
return { kind: "skip", reason: "focus-filter-not-touched" };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "patch",
|
||||
patch: {
|
||||
focused: {
|
||||
[gatewayKey]: {
|
||||
mode: "focused",
|
||||
filter: params.focusFilter,
|
||||
},
|
||||
},
|
||||
},
|
||||
debounceMs: FOCUSED_PATCH_DEBOUNCE_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export type FocusedSelectionPatchIntent =
|
||||
| {
|
||||
kind: "skip";
|
||||
reason:
|
||||
| "missing-gateway-key"
|
||||
| "not-connected"
|
||||
| "focused-preferences-not-loaded"
|
||||
| "agents-not-loaded";
|
||||
}
|
||||
| {
|
||||
kind: "patch";
|
||||
patch: StudioSettingsPatch;
|
||||
debounceMs: number;
|
||||
};
|
||||
|
||||
export function planFocusedSelectionPatch(params: {
|
||||
gatewayKey: string;
|
||||
status: "connected" | "connecting" | "disconnected";
|
||||
focusedPreferencesLoaded: boolean;
|
||||
agentsLoadedOnce: boolean;
|
||||
selectedAgentId: string | null;
|
||||
}): FocusedSelectionPatchIntent {
|
||||
const gatewayKey = params.gatewayKey.trim();
|
||||
if (!gatewayKey) {
|
||||
return { kind: "skip", reason: "missing-gateway-key" };
|
||||
}
|
||||
if (params.status !== "connected") {
|
||||
return { kind: "skip", reason: "not-connected" };
|
||||
}
|
||||
if (!params.focusedPreferencesLoaded) {
|
||||
return { kind: "skip", reason: "focused-preferences-not-loaded" };
|
||||
}
|
||||
if (!params.agentsLoadedOnce) {
|
||||
return { kind: "skip", reason: "agents-not-loaded" };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "patch",
|
||||
patch: {
|
||||
focused: {
|
||||
[gatewayKey]: {
|
||||
mode: "focused",
|
||||
selectedAgentId: params.selectedAgentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
debounceMs: FOCUSED_PATCH_DEBOUNCE_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export type FocusedPreferenceRestoreIntent = {
|
||||
preferredSelectedAgentId: string | null;
|
||||
focusFilter: FocusFilter;
|
||||
};
|
||||
|
||||
export function planFocusedPreferenceRestore(params: {
|
||||
settings: StudioSettings | StudioSettingsPublic | null;
|
||||
gatewayKey: string;
|
||||
focusFilterTouched: boolean;
|
||||
}): FocusedPreferenceRestoreIntent {
|
||||
const gatewayKey = params.gatewayKey.trim();
|
||||
if (!gatewayKey || params.focusFilterTouched || !params.settings) {
|
||||
return {
|
||||
preferredSelectedAgentId: null,
|
||||
focusFilter: "all",
|
||||
};
|
||||
}
|
||||
|
||||
const preference = resolveFocusedPreference(params.settings, gatewayKey);
|
||||
if (!preference) {
|
||||
return {
|
||||
preferredSelectedAgentId: null,
|
||||
focusFilter: "all",
|
||||
};
|
||||
}
|
||||
|
||||
const restoredFilter = preference.filter === "running" ? "all" : preference.filter;
|
||||
return {
|
||||
preferredSelectedAgentId: preference.selectedAgentId,
|
||||
focusFilter: restoredFilter,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createRafBatcher } from "@/lib/dom";
|
||||
import {
|
||||
planDraftFlushIntent,
|
||||
planDraftTimerIntent,
|
||||
planNewSessionIntent,
|
||||
planStopRunIntent,
|
||||
} from "@/features/agents/operations/chatInteractionWorkflow";
|
||||
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
|
||||
import { mergePendingLivePatch } from "@/features/agents/state/livePatchQueue";
|
||||
import { buildNewSessionAgentPatch, type AgentState } from "@/features/agents/state/store";
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
type ChatInteractionDispatchAction =
|
||||
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
| { type: "appendOutput"; agentId: string; line: string }
|
||||
| { type: "enqueueQueuedMessage"; agentId: string; message: string }
|
||||
| { type: "removeQueuedMessage"; agentId: string; index: number }
|
||||
| { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string };
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type UseChatInteractionControllerParams = {
|
||||
client: GatewayClientLike;
|
||||
status: GatewayStatus;
|
||||
agents: AgentState[];
|
||||
dispatch: (action: ChatInteractionDispatchAction) => void;
|
||||
setError: (message: string) => void;
|
||||
getAgents: () => AgentState[];
|
||||
clearRunTracking: (runId?: string | null) => void;
|
||||
clearHistoryInFlight: (sessionKey: string) => void;
|
||||
clearSpecialUpdateMarker: (agentId: string) => void;
|
||||
clearSpecialLatestUpdateInFlight: (agentId: string) => void;
|
||||
setInspectSidebarNull: () => void;
|
||||
setMobilePaneChat: () => void;
|
||||
draftDebounceMs?: number;
|
||||
};
|
||||
|
||||
export type ChatInteractionController = {
|
||||
stopBusyAgentId: string | null;
|
||||
flushPendingDraft: (agentId: string | null) => void;
|
||||
handleDraftChange: (agentId: string, value: string) => void;
|
||||
handleSend: (agentId: string, sessionKey: string, message: string) => Promise<void>;
|
||||
removeQueuedMessage: (agentId: string, index: number) => void;
|
||||
handleNewSession: (agentId: string) => Promise<void>;
|
||||
handleStopRun: (agentId: string, sessionKey: string) => Promise<void>;
|
||||
queueLivePatch: (agentId: string, patch: Partial<AgentState>) => void;
|
||||
clearPendingLivePatch: (agentId: string) => void;
|
||||
};
|
||||
|
||||
export function useChatInteractionController(
|
||||
params: UseChatInteractionControllerParams
|
||||
): ChatInteractionController {
|
||||
const [stopBusyAgentId, setStopBusyAgentId] = useState<string | null>(null);
|
||||
const stopBusyAgentIdRef = useRef<string | null>(stopBusyAgentId);
|
||||
const pendingDraftValuesRef = useRef<Map<string, string>>(new Map());
|
||||
const pendingDraftTimersRef = useRef<Map<string, number>>(new Map());
|
||||
const pendingLivePatchesRef = useRef<Map<string, Partial<AgentState>>>(new Map());
|
||||
const activeQueueSendAgentIdsRef = useRef<Set<string>>(new Set());
|
||||
const flushLivePatchesRef = useRef<() => void>(() => {});
|
||||
const livePatchBatcherRef = useRef(createRafBatcher(() => flushLivePatchesRef.current()));
|
||||
|
||||
useEffect(() => {
|
||||
stopBusyAgentIdRef.current = stopBusyAgentId;
|
||||
}, [stopBusyAgentId]);
|
||||
|
||||
const flushPendingDraft = useCallback(
|
||||
(agentId: string | null) => {
|
||||
const hasPendingValue = Boolean(agentId && pendingDraftValuesRef.current.has(agentId));
|
||||
const flushIntent = planDraftFlushIntent({
|
||||
agentId,
|
||||
hasPendingValue,
|
||||
});
|
||||
if (flushIntent.kind !== "flush") return;
|
||||
|
||||
const timer = pendingDraftTimersRef.current.get(flushIntent.agentId) ?? null;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
pendingDraftTimersRef.current.delete(flushIntent.agentId);
|
||||
}
|
||||
|
||||
const value = pendingDraftValuesRef.current.get(flushIntent.agentId);
|
||||
if (value === undefined) return;
|
||||
pendingDraftValuesRef.current.delete(flushIntent.agentId);
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId: flushIntent.agentId,
|
||||
patch: { draft: value },
|
||||
});
|
||||
},
|
||||
[params]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = pendingDraftTimersRef.current;
|
||||
const values = pendingDraftValuesRef.current;
|
||||
return () => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
timers.clear();
|
||||
values.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const flushPendingLivePatches = useCallback(() => {
|
||||
const pending = pendingLivePatchesRef.current;
|
||||
if (pending.size === 0) return;
|
||||
const entries = [...pending.entries()];
|
||||
pending.clear();
|
||||
for (const [agentId, patch] of entries) {
|
||||
params.dispatch({ type: "updateAgent", agentId, patch });
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
flushLivePatchesRef.current = flushPendingLivePatches;
|
||||
}, [flushPendingLivePatches]);
|
||||
|
||||
useEffect(() => {
|
||||
const batcher = livePatchBatcherRef.current;
|
||||
const pending = pendingLivePatchesRef.current;
|
||||
return () => {
|
||||
batcher.cancel();
|
||||
pending.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const queueLivePatch = useCallback((agentId: string, patch: Partial<AgentState>) => {
|
||||
const key = agentId.trim();
|
||||
if (!key) return;
|
||||
const existing = pendingLivePatchesRef.current.get(key);
|
||||
pendingLivePatchesRef.current.set(key, mergePendingLivePatch(existing, patch));
|
||||
livePatchBatcherRef.current.schedule();
|
||||
}, []);
|
||||
|
||||
const clearPendingLivePatch = useCallback((agentId: string) => {
|
||||
const key = agentId.trim();
|
||||
if (!key) return;
|
||||
const pending = pendingLivePatchesRef.current;
|
||||
if (!pending.has(key)) return;
|
||||
pending.delete(key);
|
||||
if (pending.size === 0) {
|
||||
livePatchBatcherRef.current.cancel();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(agentId: string, value: string) => {
|
||||
pendingDraftValuesRef.current.set(agentId, value);
|
||||
const existingTimer = pendingDraftTimersRef.current.get(agentId) ?? null;
|
||||
if (existingTimer !== null) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timerIntent = planDraftTimerIntent({
|
||||
agentId,
|
||||
delayMs: params.draftDebounceMs,
|
||||
});
|
||||
if (timerIntent.kind !== "schedule") {
|
||||
pendingDraftTimersRef.current.delete(agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
pendingDraftTimersRef.current.delete(agentId);
|
||||
const pendingValue = pendingDraftValuesRef.current.get(agentId);
|
||||
const flushIntent = planDraftFlushIntent({
|
||||
agentId,
|
||||
hasPendingValue: pendingValue !== undefined,
|
||||
});
|
||||
if (flushIntent.kind !== "flush" || pendingValue === undefined) return;
|
||||
pendingDraftValuesRef.current.delete(agentId);
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { draft: pendingValue },
|
||||
});
|
||||
}, timerIntent.delayMs);
|
||||
pendingDraftTimersRef.current.set(agentId, timer);
|
||||
},
|
||||
[params]
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (agentId: string, sessionKey: string, message: string) => {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return;
|
||||
const pendingDraftTimer = pendingDraftTimersRef.current.get(agentId) ?? null;
|
||||
if (pendingDraftTimer !== null) {
|
||||
window.clearTimeout(pendingDraftTimer);
|
||||
pendingDraftTimersRef.current.delete(agentId);
|
||||
}
|
||||
pendingDraftValuesRef.current.delete(agentId);
|
||||
const agent =
|
||||
params.agents.find((entry) => entry.agentId === agentId) ??
|
||||
params.getAgents().find((entry) => entry.agentId === agentId) ??
|
||||
null;
|
||||
if (!agent) {
|
||||
params.dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: "Error: Agent not found.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (agent.status === "running") {
|
||||
params.dispatch({
|
||||
type: "enqueueQueuedMessage",
|
||||
agentId,
|
||||
message: trimmed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
clearPendingLivePatch(agent.agentId);
|
||||
await sendChatMessageViaStudio({
|
||||
client: params.client,
|
||||
dispatch: params.dispatch,
|
||||
getAgent: (currentAgentId) =>
|
||||
params.getAgents().find((entry) => entry.agentId === currentAgentId) ?? null,
|
||||
agentId,
|
||||
sessionKey,
|
||||
message: trimmed,
|
||||
clearRunTracking: (runId) => params.clearRunTracking(runId),
|
||||
});
|
||||
},
|
||||
[clearPendingLivePatch, params]
|
||||
);
|
||||
|
||||
const removeQueuedMessage = useCallback(
|
||||
(agentId: string, index: number) => {
|
||||
if (!Number.isInteger(index) || index < 0) return;
|
||||
params.dispatch({
|
||||
type: "removeQueuedMessage",
|
||||
agentId,
|
||||
index,
|
||||
});
|
||||
},
|
||||
[params]
|
||||
);
|
||||
|
||||
const sendNextQueuedMessage = useCallback(
|
||||
async (agent: Pick<AgentState, "agentId" | "sessionKey"> & { nextMessage: string }) => {
|
||||
if (params.status !== "connected") return;
|
||||
const nextMessage = agent.nextMessage.trim();
|
||||
if (!nextMessage) return;
|
||||
params.dispatch({
|
||||
type: "shiftQueuedMessage",
|
||||
agentId: agent.agentId,
|
||||
expectedMessage: nextMessage,
|
||||
});
|
||||
clearPendingLivePatch(agent.agentId);
|
||||
await sendChatMessageViaStudio({
|
||||
client: params.client,
|
||||
dispatch: params.dispatch,
|
||||
getAgent: (currentAgentId) =>
|
||||
params.getAgents().find((entry) => entry.agentId === currentAgentId) ?? null,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: nextMessage,
|
||||
clearRunTracking: (runId) => params.clearRunTracking(runId),
|
||||
});
|
||||
},
|
||||
[clearPendingLivePatch, params]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (params.status !== "connected") return;
|
||||
for (const agent of params.agents) {
|
||||
if (agent.status !== "idle") continue;
|
||||
const nextMessage = agent.queuedMessages?.[0];
|
||||
if (!nextMessage) continue;
|
||||
if (activeQueueSendAgentIdsRef.current.has(agent.agentId)) continue;
|
||||
activeQueueSendAgentIdsRef.current.add(agent.agentId);
|
||||
void (async () => {
|
||||
try {
|
||||
await sendNextQueuedMessage({
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
nextMessage,
|
||||
});
|
||||
} finally {
|
||||
activeQueueSendAgentIdsRef.current.delete(agent.agentId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [params.agents, params.status, sendNextQueuedMessage]);
|
||||
|
||||
const handleStopRun = useCallback(
|
||||
async (agentId: string, sessionKey: string) => {
|
||||
const stopIntent = planStopRunIntent({
|
||||
status: params.status,
|
||||
agentId,
|
||||
sessionKey,
|
||||
busyAgentId: stopBusyAgentIdRef.current,
|
||||
});
|
||||
if (stopIntent.kind === "deny") {
|
||||
params.setError(stopIntent.message);
|
||||
return;
|
||||
}
|
||||
if (stopIntent.kind === "skip-busy") {
|
||||
return;
|
||||
}
|
||||
|
||||
setStopBusyAgentId(agentId);
|
||||
stopBusyAgentIdRef.current = agentId;
|
||||
try {
|
||||
await params.client.call("chat.abort", {
|
||||
sessionKey: stopIntent.sessionKey,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to stop run.";
|
||||
params.setError(message);
|
||||
console.error(message);
|
||||
params.dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: `Stop failed: ${message}`,
|
||||
});
|
||||
} finally {
|
||||
setStopBusyAgentId((current) => {
|
||||
const next = current === agentId ? null : current;
|
||||
stopBusyAgentIdRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[params]
|
||||
);
|
||||
|
||||
const handleNewSession = useCallback(
|
||||
async (agentId: string) => {
|
||||
const agent = params.getAgents().find((entry) => entry.agentId === agentId);
|
||||
const newSessionIntent = planNewSessionIntent({
|
||||
hasAgent: Boolean(agent),
|
||||
sessionKey: agent?.sessionKey ?? "",
|
||||
});
|
||||
if (newSessionIntent.kind === "deny" && newSessionIntent.reason === "missing-agent") {
|
||||
params.setError(newSessionIntent.message);
|
||||
return;
|
||||
}
|
||||
if (!agent) return;
|
||||
|
||||
try {
|
||||
if (newSessionIntent.kind === "deny") {
|
||||
throw new Error(newSessionIntent.message);
|
||||
}
|
||||
await params.client.call("sessions.reset", { key: newSessionIntent.sessionKey });
|
||||
const patch = buildNewSessionAgentPatch(agent);
|
||||
params.clearRunTracking(agent.runId);
|
||||
params.clearHistoryInFlight(newSessionIntent.sessionKey);
|
||||
params.clearSpecialUpdateMarker(agentId);
|
||||
params.clearSpecialLatestUpdateInFlight(agentId);
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch,
|
||||
});
|
||||
params.setInspectSidebarNull();
|
||||
params.setMobilePaneChat();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to start new session.";
|
||||
params.setError(message);
|
||||
params.dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: `New session failed: ${message}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[params]
|
||||
);
|
||||
|
||||
return {
|
||||
stopBusyAgentId,
|
||||
flushPendingDraft,
|
||||
handleDraftChange,
|
||||
handleSend,
|
||||
removeQueuedMessage,
|
||||
handleNewSession,
|
||||
handleStopRun,
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { shouldStartNextConfigMutation } from "@/features/agents/operations/configMutationGatePolicy";
|
||||
import type { GatewayStatus } from "@/features/agents/operations/gatewayRestartPolicy";
|
||||
import { randomUUID } from "@/lib/uuid";
|
||||
|
||||
export type ConfigMutationKind =
|
||||
| "create-agent"
|
||||
| "rename-agent"
|
||||
| "delete-agent"
|
||||
| "update-agent-execution-role"
|
||||
| "update-agent-permissions"
|
||||
| "update-agent-skills"
|
||||
| "update-skill-setup"
|
||||
| "repair-sandbox-tool-allowlist";
|
||||
|
||||
type QueuedConfigMutation = {
|
||||
id: string;
|
||||
kind: ConfigMutationKind;
|
||||
label: string;
|
||||
requiresIdleAgents: boolean;
|
||||
run: () => Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export type ActiveConfigMutation = {
|
||||
kind: ConfigMutationKind;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const mutationRequiresIdleAgents = (kind: ConfigMutationKind): boolean =>
|
||||
kind === "create-agent" || kind === "rename-agent" || kind === "delete-agent";
|
||||
|
||||
export function useConfigMutationQueue(params: {
|
||||
status: GatewayStatus;
|
||||
hasRunningAgents: boolean;
|
||||
hasRestartBlockInProgress: boolean;
|
||||
}) {
|
||||
const [queuedConfigMutations, setQueuedConfigMutations] = useState<QueuedConfigMutation[]>([]);
|
||||
const [activeConfigMutation, setActiveConfigMutation] = useState<QueuedConfigMutation | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const enqueueConfigMutation = useCallback(
|
||||
(params: {
|
||||
kind: ConfigMutationKind;
|
||||
label: string;
|
||||
run: () => Promise<void>;
|
||||
requiresIdleAgents?: boolean;
|
||||
}) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const queued: QueuedConfigMutation = {
|
||||
id: randomUUID(),
|
||||
kind: params.kind,
|
||||
label: params.label,
|
||||
requiresIdleAgents: params.requiresIdleAgents ?? mutationRequiresIdleAgents(params.kind),
|
||||
run: params.run,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
setQueuedConfigMutations((current) => [...current, queued]);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldStartNextConfigMutation({
|
||||
status: params.status,
|
||||
hasRunningAgents: params.hasRunningAgents,
|
||||
nextMutationRequiresIdleAgents: Boolean(queuedConfigMutations[0]?.requiresIdleAgents),
|
||||
hasActiveMutation: Boolean(activeConfigMutation),
|
||||
hasRestartBlockInProgress: params.hasRestartBlockInProgress,
|
||||
queuedCount: queuedConfigMutations.length,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = queuedConfigMutations[0];
|
||||
if (!next) return;
|
||||
setQueuedConfigMutations((current) => current.slice(1));
|
||||
setActiveConfigMutation(next);
|
||||
}, [
|
||||
activeConfigMutation,
|
||||
params.hasRestartBlockInProgress,
|
||||
params.hasRunningAgents,
|
||||
params.status,
|
||||
queuedConfigMutations,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeConfigMutation) return;
|
||||
let mounted = true;
|
||||
const run = async () => {
|
||||
try {
|
||||
await activeConfigMutation.run();
|
||||
activeConfigMutation.resolve();
|
||||
} catch (error) {
|
||||
activeConfigMutation.reject(error);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setActiveConfigMutation(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [activeConfigMutation]);
|
||||
|
||||
return {
|
||||
enqueueConfigMutation,
|
||||
queuedCount: queuedConfigMutations.length,
|
||||
queuedBlockedByRunningAgents:
|
||||
Boolean(queuedConfigMutations[0]?.requiresIdleAgents) && params.hasRunningAgents,
|
||||
activeConfigMutation: activeConfigMutation
|
||||
? ({ kind: activeConfigMutation.kind, label: activeConfigMutation.label } satisfies ActiveConfigMutation)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
resolveGatewayModelsSyncIntent,
|
||||
resolveSandboxRepairIntent,
|
||||
shouldRefreshGatewayConfigForSettingsRoute,
|
||||
type GatewayConnectionStatus,
|
||||
} from "@/features/agents/operations/gatewayConfigSyncWorkflow";
|
||||
import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig";
|
||||
import {
|
||||
buildGatewayModelChoices,
|
||||
type GatewayModelChoice,
|
||||
type GatewayModelPolicySnapshot,
|
||||
} from "@/lib/gateway/models";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const defaultLogError = (message: string, err: unknown) => {
|
||||
console.error(message, err);
|
||||
};
|
||||
|
||||
export type UseGatewayConfigSyncControllerParams = {
|
||||
client: GatewayClient;
|
||||
status: GatewayConnectionStatus;
|
||||
settingsRouteActive: boolean;
|
||||
inspectSidebarAgentId: string | null;
|
||||
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
|
||||
setGatewayConfigSnapshot: (snapshot: GatewayModelPolicySnapshot | null) => void;
|
||||
setGatewayModels: (models: GatewayModelChoice[]) => void;
|
||||
setGatewayModelsError: (message: string | null) => void;
|
||||
enqueueConfigMutation: (params: {
|
||||
kind: "repair-sandbox-tool-allowlist";
|
||||
label: string;
|
||||
run: () => Promise<void>;
|
||||
requiresIdleAgents?: boolean;
|
||||
}) => Promise<void>;
|
||||
loadAgents: () => Promise<void>;
|
||||
isDisconnectLikeError: (err: unknown) => boolean;
|
||||
logError?: (message: string, err: unknown) => void;
|
||||
};
|
||||
|
||||
export type GatewayConfigSyncController = {
|
||||
refreshGatewayConfigSnapshot: () => Promise<GatewayModelPolicySnapshot | null>;
|
||||
};
|
||||
|
||||
export function useGatewayConfigSyncController(
|
||||
params: UseGatewayConfigSyncControllerParams
|
||||
): GatewayConfigSyncController {
|
||||
const sandboxRepairAttemptedRef = useRef(false);
|
||||
const {
|
||||
client,
|
||||
status,
|
||||
settingsRouteActive,
|
||||
inspectSidebarAgentId,
|
||||
gatewayConfigSnapshot,
|
||||
setGatewayConfigSnapshot,
|
||||
setGatewayModels,
|
||||
setGatewayModelsError,
|
||||
enqueueConfigMutation,
|
||||
loadAgents,
|
||||
isDisconnectLikeError,
|
||||
} = params;
|
||||
|
||||
const logError = params.logError ?? defaultLogError;
|
||||
|
||||
const refreshGatewayConfigSnapshot = useCallback(async () => {
|
||||
if (status !== "connected") return null;
|
||||
try {
|
||||
const snapshot = await client.call<GatewayModelPolicySnapshot>("config.get", {});
|
||||
setGatewayConfigSnapshot(snapshot);
|
||||
return snapshot;
|
||||
} catch (err) {
|
||||
if (!isDisconnectLikeError(err)) {
|
||||
logError("Failed to refresh gateway config.", err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [client, isDisconnectLikeError, setGatewayConfigSnapshot, status, logError]);
|
||||
|
||||
useEffect(() => {
|
||||
const repairIntent = resolveSandboxRepairIntent({
|
||||
status,
|
||||
attempted: sandboxRepairAttemptedRef.current,
|
||||
snapshot: gatewayConfigSnapshot,
|
||||
});
|
||||
if (repairIntent.kind !== "repair") return;
|
||||
|
||||
sandboxRepairAttemptedRef.current = true;
|
||||
void enqueueConfigMutation({
|
||||
kind: "repair-sandbox-tool-allowlist",
|
||||
label: "Repair sandbox tool access",
|
||||
run: async () => {
|
||||
for (const agentId of repairIntent.agentIds) {
|
||||
await updateGatewayAgentOverrides({
|
||||
client,
|
||||
agentId,
|
||||
overrides: {
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: ["*"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
await loadAgents();
|
||||
},
|
||||
});
|
||||
}, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldRefreshGatewayConfigForSettingsRoute({
|
||||
status,
|
||||
settingsRouteActive,
|
||||
inspectSidebarAgentId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void refreshGatewayConfigSnapshot();
|
||||
}, [inspectSidebarAgentId, refreshGatewayConfigSnapshot, settingsRouteActive, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncIntent = resolveGatewayModelsSyncIntent({ status });
|
||||
if (syncIntent.kind === "clear") {
|
||||
setGatewayModels([]);
|
||||
setGatewayModelsError(null);
|
||||
setGatewayConfigSnapshot(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadModels = async () => {
|
||||
let configSnapshot: GatewayModelPolicySnapshot | null = null;
|
||||
try {
|
||||
configSnapshot = await client.call<GatewayModelPolicySnapshot>("config.get", {});
|
||||
if (!cancelled) {
|
||||
setGatewayConfigSnapshot(configSnapshot);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isDisconnectLikeError(err)) {
|
||||
logError("Failed to load gateway config.", err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.call<{ models: GatewayModelChoice[] }>(
|
||||
"models.list",
|
||||
{}
|
||||
);
|
||||
if (cancelled) return;
|
||||
const catalog = Array.isArray(result.models) ? result.models : [];
|
||||
setGatewayModels(buildGatewayModelChoices(catalog, configSnapshot));
|
||||
setGatewayModelsError(null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
const message = err instanceof Error ? err.message : "Failed to load models.";
|
||||
setGatewayModelsError(message);
|
||||
setGatewayModels([]);
|
||||
if (!isDisconnectLikeError(err)) {
|
||||
logError("Failed to load gateway models.", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadModels();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
isDisconnectLikeError,
|
||||
setGatewayConfigSnapshot,
|
||||
setGatewayModels,
|
||||
setGatewayModelsError,
|
||||
status,
|
||||
logError,
|
||||
]);
|
||||
|
||||
return {
|
||||
refreshGatewayConfigSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { observeGatewayRestart, type GatewayStatus } from "@/features/agents/operations/gatewayRestartPolicy";
|
||||
|
||||
type RestartBlockState = {
|
||||
phase: string;
|
||||
startedAt: number;
|
||||
sawDisconnect: boolean;
|
||||
};
|
||||
|
||||
export function useGatewayRestartBlock<T extends RestartBlockState>(params: {
|
||||
status: GatewayStatus;
|
||||
block: T | null;
|
||||
setBlock: Dispatch<SetStateAction<T | null>>;
|
||||
maxWaitMs: number;
|
||||
onRestartComplete: (block: T, ctx: { isCancelled: () => boolean }) => void | Promise<void>;
|
||||
onTimeout: () => void;
|
||||
}) {
|
||||
const { block, maxWaitMs, onRestartComplete, onTimeout, setBlock, status } = params;
|
||||
const onRestartCompleteRef = useRef(onRestartComplete);
|
||||
const onTimeoutRef = useRef(onTimeout);
|
||||
|
||||
useEffect(() => {
|
||||
onRestartCompleteRef.current = onRestartComplete;
|
||||
onTimeoutRef.current = onTimeout;
|
||||
}, [onRestartComplete, onTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!block || block.phase !== "awaiting-restart") return;
|
||||
|
||||
const observed = observeGatewayRestart({ sawDisconnect: block.sawDisconnect }, status);
|
||||
|
||||
if (!block.sawDisconnect && observed.next.sawDisconnect) {
|
||||
setBlock((current) => {
|
||||
if (!current || current.phase !== "awaiting-restart" || current.sawDisconnect) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, sawDisconnect: true };
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!observed.restartComplete) return;
|
||||
|
||||
const currentBlock = block;
|
||||
let cancelled = false;
|
||||
const finalize = async () => {
|
||||
await onRestartCompleteRef.current(currentBlock, { isCancelled: () => cancelled });
|
||||
};
|
||||
void finalize();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [block, setBlock, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!block) return;
|
||||
if (block.phase === "queued") return;
|
||||
const elapsed = Date.now() - block.startedAt;
|
||||
const remaining = Math.max(0, maxWaitMs - elapsed);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
onTimeoutRef.current();
|
||||
}, remaining);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [block, maxWaitMs]);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
executeAgentReconcileCommands,
|
||||
runAgentReconcileOperation,
|
||||
} from "@/features/agents/operations/agentReconcileOperation";
|
||||
import { resolveSummarySnapshotIntent } from "@/features/agents/operations/fleetLifecycleWorkflow";
|
||||
import {
|
||||
executeHistorySyncCommands,
|
||||
runHistorySyncOperation,
|
||||
} from "@/features/agents/operations/historySyncOperation";
|
||||
import {
|
||||
RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT,
|
||||
RUNTIME_SYNC_MAX_HISTORY_LIMIT,
|
||||
resolveRuntimeSyncBootstrapHistoryAgentIds,
|
||||
resolveRuntimeSyncFocusedHistoryPollingIntent,
|
||||
resolveRuntimeSyncGapRecoveryIntent,
|
||||
resolveRuntimeSyncLoadMoreHistoryLimit,
|
||||
resolveRuntimeSyncReconcilePollingIntent,
|
||||
shouldRuntimeSyncContinueFocusedHistoryPolling,
|
||||
} from "@/features/agents/operations/runtimeSyncControlWorkflow";
|
||||
import {
|
||||
buildSummarySnapshotPatches,
|
||||
type SummaryPreviewSnapshot,
|
||||
type SummaryStatusSnapshot,
|
||||
} from "@/features/agents/state/runtimeEventBridge";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { TRANSCRIPT_V2_ENABLED, logTranscriptDebugMetric } from "@/features/agents/state/transcript";
|
||||
import { randomUUID } from "@/lib/uuid";
|
||||
|
||||
type RuntimeSyncDispatchAction = {
|
||||
type: "updateAgent";
|
||||
agentId: string;
|
||||
patch: Partial<AgentState>;
|
||||
};
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: <T = unknown>(method: string, params: unknown) => Promise<T>;
|
||||
onGap: (handler: (info: { expected: number; received: number }) => void) => () => void;
|
||||
};
|
||||
|
||||
export type UseRuntimeSyncControllerParams = {
|
||||
client: GatewayClientLike;
|
||||
status: "disconnected" | "connecting" | "connected";
|
||||
agents: AgentState[];
|
||||
focusedAgentId: string | null;
|
||||
focusedAgentRunning: boolean;
|
||||
dispatch: (action: RuntimeSyncDispatchAction) => void;
|
||||
clearRunTracking: (runId: string) => void;
|
||||
isDisconnectLikeError: (error: unknown) => boolean;
|
||||
defaultHistoryLimit?: number;
|
||||
maxHistoryLimit?: number;
|
||||
};
|
||||
|
||||
export type RuntimeSyncController = {
|
||||
loadSummarySnapshot: () => Promise<void>;
|
||||
loadAgentHistory: (agentId: string, options?: { limit?: number }) => Promise<void>;
|
||||
loadMoreAgentHistory: (agentId: string) => void;
|
||||
reconcileRunningAgents: () => Promise<void>;
|
||||
clearHistoryInFlight: (sessionKey: string) => void;
|
||||
};
|
||||
|
||||
export function useRuntimeSyncController(
|
||||
params: UseRuntimeSyncControllerParams
|
||||
): RuntimeSyncController {
|
||||
const {
|
||||
client,
|
||||
status,
|
||||
agents,
|
||||
focusedAgentId,
|
||||
focusedAgentRunning,
|
||||
dispatch,
|
||||
clearRunTracking,
|
||||
isDisconnectLikeError,
|
||||
} = params;
|
||||
const agentsRef = useRef(params.agents);
|
||||
const historyInFlightRef = useRef<Set<string>>(new Set());
|
||||
const reconcileRunInFlightRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const defaultHistoryLimit = params.defaultHistoryLimit ?? RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT;
|
||||
const maxHistoryLimit = params.maxHistoryLimit ?? RUNTIME_SYNC_MAX_HISTORY_LIMIT;
|
||||
|
||||
useEffect(() => {
|
||||
agentsRef.current = agents;
|
||||
}, [agents]);
|
||||
|
||||
const clearHistoryInFlight = useCallback((sessionKey: string) => {
|
||||
const key = sessionKey.trim();
|
||||
if (!key) return;
|
||||
historyInFlightRef.current.delete(key);
|
||||
}, []);
|
||||
|
||||
const loadSummarySnapshot = useCallback(async () => {
|
||||
const snapshotAgents = agentsRef.current;
|
||||
const summaryIntent = resolveSummarySnapshotIntent({
|
||||
agents: snapshotAgents,
|
||||
maxKeys: 64,
|
||||
});
|
||||
if (summaryIntent.kind === "skip") return;
|
||||
const activeAgents = snapshotAgents.filter((agent) => agent.sessionCreated);
|
||||
try {
|
||||
const [statusSummary, previewResult] = await Promise.all([
|
||||
client.call<SummaryStatusSnapshot>("status", {}),
|
||||
client.call<SummaryPreviewSnapshot>("sessions.preview", {
|
||||
keys: summaryIntent.keys,
|
||||
limit: summaryIntent.limit,
|
||||
maxChars: summaryIntent.maxChars,
|
||||
}),
|
||||
]);
|
||||
for (const entry of buildSummarySnapshotPatches({
|
||||
agents: activeAgents,
|
||||
statusSummary,
|
||||
previewResult,
|
||||
})) {
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId: entry.agentId,
|
||||
patch: entry.patch,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isDisconnectLikeError(error)) {
|
||||
console.error("Failed to load summary snapshot.", error);
|
||||
}
|
||||
}
|
||||
}, [client, dispatch, isDisconnectLikeError]);
|
||||
|
||||
const loadAgentHistory = useCallback(
|
||||
async (agentId: string, options?: { limit?: number }) => {
|
||||
const commands = await runHistorySyncOperation({
|
||||
client,
|
||||
agentId,
|
||||
requestedLimit: options?.limit,
|
||||
getAgent: (targetAgentId) =>
|
||||
agentsRef.current.find((entry) => entry.agentId === targetAgentId) ?? null,
|
||||
inFlightSessionKeys: historyInFlightRef.current,
|
||||
requestId: randomUUID(),
|
||||
loadedAt: Date.now(),
|
||||
defaultLimit: defaultHistoryLimit,
|
||||
maxLimit: maxHistoryLimit,
|
||||
transcriptV2Enabled: TRANSCRIPT_V2_ENABLED,
|
||||
});
|
||||
executeHistorySyncCommands({
|
||||
commands,
|
||||
dispatch,
|
||||
logMetric: (metric, meta) => logTranscriptDebugMetric(metric, meta),
|
||||
isDisconnectLikeError,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
});
|
||||
},
|
||||
[client, defaultHistoryLimit, dispatch, isDisconnectLikeError, maxHistoryLimit]
|
||||
);
|
||||
|
||||
const loadMoreAgentHistory = useCallback(
|
||||
(agentId: string) => {
|
||||
const agent = agentsRef.current.find((entry) => entry.agentId === agentId) ?? null;
|
||||
const nextLimit = resolveRuntimeSyncLoadMoreHistoryLimit({
|
||||
currentLimit: agent?.historyFetchLimit ?? null,
|
||||
defaultLimit: defaultHistoryLimit,
|
||||
maxLimit: maxHistoryLimit,
|
||||
});
|
||||
void loadAgentHistory(agentId, { limit: nextLimit });
|
||||
},
|
||||
[defaultHistoryLimit, loadAgentHistory, maxHistoryLimit]
|
||||
);
|
||||
|
||||
const reconcileRunningAgents = useCallback(async () => {
|
||||
if (status !== "connected") return;
|
||||
const commands = await runAgentReconcileOperation({
|
||||
client,
|
||||
agents: agentsRef.current,
|
||||
getLatestAgent: (agentId) =>
|
||||
agentsRef.current.find((entry) => entry.agentId === agentId) ?? null,
|
||||
claimRunId: (runId) => {
|
||||
const normalized = runId.trim();
|
||||
if (!normalized) return false;
|
||||
if (reconcileRunInFlightRef.current.has(normalized)) return false;
|
||||
reconcileRunInFlightRef.current.add(normalized);
|
||||
return true;
|
||||
},
|
||||
releaseRunId: (runId) => {
|
||||
const normalized = runId.trim();
|
||||
if (!normalized) return;
|
||||
reconcileRunInFlightRef.current.delete(normalized);
|
||||
},
|
||||
isDisconnectLikeError,
|
||||
});
|
||||
executeAgentReconcileCommands({
|
||||
commands,
|
||||
dispatch,
|
||||
clearRunTracking,
|
||||
requestHistoryRefresh: (agentId) => {
|
||||
void loadAgentHistory(agentId);
|
||||
},
|
||||
logInfo: (message) => console.info(message),
|
||||
logWarn: (message, error) => console.warn(message, error),
|
||||
});
|
||||
}, [clearRunTracking, client, dispatch, isDisconnectLikeError, loadAgentHistory, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "connected") return;
|
||||
void loadSummarySnapshot();
|
||||
}, [loadSummarySnapshot, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const reconcileIntent = resolveRuntimeSyncReconcilePollingIntent({
|
||||
status,
|
||||
});
|
||||
if (reconcileIntent.kind === "stop") return;
|
||||
void reconcileRunningAgents();
|
||||
const timer = window.setInterval(() => {
|
||||
void reconcileRunningAgents();
|
||||
}, reconcileIntent.intervalMs);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [reconcileRunningAgents, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const bootstrapAgentIds = resolveRuntimeSyncBootstrapHistoryAgentIds({
|
||||
status,
|
||||
agents,
|
||||
});
|
||||
for (const agentId of bootstrapAgentIds) {
|
||||
void loadAgentHistory(agentId);
|
||||
}
|
||||
}, [agents, loadAgentHistory, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const pollingIntent = resolveRuntimeSyncFocusedHistoryPollingIntent({
|
||||
status,
|
||||
focusedAgentId,
|
||||
focusedAgentRunning,
|
||||
});
|
||||
if (pollingIntent.kind === "stop") return;
|
||||
void loadAgentHistory(pollingIntent.agentId);
|
||||
const timer = window.setInterval(() => {
|
||||
const shouldContinue = shouldRuntimeSyncContinueFocusedHistoryPolling({
|
||||
agentId: pollingIntent.agentId,
|
||||
agents: agentsRef.current,
|
||||
});
|
||||
if (!shouldContinue) return;
|
||||
void loadAgentHistory(pollingIntent.agentId);
|
||||
}, pollingIntent.intervalMs);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [focusedAgentId, focusedAgentRunning, loadAgentHistory, status]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onGap((info) => {
|
||||
const recoveryIntent = resolveRuntimeSyncGapRecoveryIntent();
|
||||
console.warn(`Gateway event gap expected ${info.expected}, received ${info.received}.`);
|
||||
if (recoveryIntent.refreshSummarySnapshot) {
|
||||
void loadSummarySnapshot();
|
||||
}
|
||||
if (recoveryIntent.reconcileRunningAgents) {
|
||||
void reconcileRunningAgents();
|
||||
}
|
||||
});
|
||||
}, [client, loadSummarySnapshot, reconcileRunningAgents]);
|
||||
|
||||
return {
|
||||
loadSummarySnapshot,
|
||||
loadAgentHistory,
|
||||
loadMoreAgentHistory,
|
||||
reconcileRunningAgents,
|
||||
clearHistoryInFlight,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import {
|
||||
planBackToChatCommands,
|
||||
planFleetSelectCommands,
|
||||
planNonRouteSelectionSyncCommands,
|
||||
planOpenSettingsRouteCommands,
|
||||
planSettingsRouteSyncCommands,
|
||||
planSettingsTabChangeCommands,
|
||||
shouldConfirmDiscardPersonalityChanges,
|
||||
type InspectSidebarState,
|
||||
type SettingsRouteNavCommand,
|
||||
type SettingsRouteTab,
|
||||
} from "@/features/agents/operations/settingsRouteWorkflow";
|
||||
|
||||
export type UseSettingsRouteControllerParams = {
|
||||
settingsRouteActive: boolean;
|
||||
settingsRouteAgentId: string | null;
|
||||
status: "disconnected" | "connecting" | "connected";
|
||||
agentsLoadedOnce: boolean;
|
||||
selectedAgentId: string | null;
|
||||
focusedAgentId: string | null;
|
||||
personalityHasUnsavedChanges: boolean;
|
||||
activeTab: SettingsRouteTab;
|
||||
inspectSidebar: InspectSidebarState;
|
||||
agents: Array<{ agentId: string }>;
|
||||
flushPendingDraft: (agentId: string | null) => void;
|
||||
dispatchSelectAgent: (agentId: string | null) => void;
|
||||
setInspectSidebar: (
|
||||
next: InspectSidebarState | ((current: InspectSidebarState) => InspectSidebarState)
|
||||
) => void;
|
||||
setMobilePaneChat: () => void;
|
||||
setPersonalityHasUnsavedChanges: (next: boolean) => void;
|
||||
push: (href: string) => void;
|
||||
replace: (href: string) => void;
|
||||
confirmDiscard: () => boolean;
|
||||
};
|
||||
|
||||
export type SettingsRouteController = {
|
||||
handleBackToChat: () => void;
|
||||
handleSettingsRouteTabChange: (nextTab: SettingsRouteTab) => void;
|
||||
handleOpenAgentSettingsRoute: (agentId: string) => void;
|
||||
handleFleetSelectAgent: (agentId: string) => void;
|
||||
};
|
||||
|
||||
const executeSettingsRouteCommands = (
|
||||
commands: SettingsRouteNavCommand[],
|
||||
params: Pick<
|
||||
UseSettingsRouteControllerParams,
|
||||
| "dispatchSelectAgent"
|
||||
| "setInspectSidebar"
|
||||
| "setMobilePaneChat"
|
||||
| "setPersonalityHasUnsavedChanges"
|
||||
| "flushPendingDraft"
|
||||
| "push"
|
||||
| "replace"
|
||||
>
|
||||
) => {
|
||||
for (const command of commands) {
|
||||
switch (command.kind) {
|
||||
case "select-agent":
|
||||
params.dispatchSelectAgent(command.agentId);
|
||||
break;
|
||||
case "set-inspect-sidebar":
|
||||
params.setInspectSidebar(command.value);
|
||||
break;
|
||||
case "set-mobile-pane-chat":
|
||||
params.setMobilePaneChat();
|
||||
break;
|
||||
case "set-personality-dirty":
|
||||
params.setPersonalityHasUnsavedChanges(command.value);
|
||||
break;
|
||||
case "flush-pending-draft":
|
||||
params.flushPendingDraft(command.agentId);
|
||||
break;
|
||||
case "push":
|
||||
params.push(command.href);
|
||||
break;
|
||||
case "replace":
|
||||
params.replace(command.href);
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = command;
|
||||
throw new Error(`Unsupported settings route command: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function useSettingsRouteController(
|
||||
params: UseSettingsRouteControllerParams
|
||||
): SettingsRouteController {
|
||||
const {
|
||||
settingsRouteActive,
|
||||
settingsRouteAgentId,
|
||||
status,
|
||||
agentsLoadedOnce,
|
||||
selectedAgentId,
|
||||
focusedAgentId,
|
||||
personalityHasUnsavedChanges,
|
||||
activeTab,
|
||||
inspectSidebar,
|
||||
agents,
|
||||
flushPendingDraft,
|
||||
dispatchSelectAgent,
|
||||
setInspectSidebar,
|
||||
setMobilePaneChat,
|
||||
setPersonalityHasUnsavedChanges,
|
||||
push,
|
||||
replace,
|
||||
confirmDiscard,
|
||||
} = params;
|
||||
const applyCommands = useCallback(
|
||||
(commands: SettingsRouteNavCommand[]) => {
|
||||
executeSettingsRouteCommands(commands, {
|
||||
dispatchSelectAgent,
|
||||
setInspectSidebar,
|
||||
setMobilePaneChat,
|
||||
setPersonalityHasUnsavedChanges,
|
||||
flushPendingDraft,
|
||||
push,
|
||||
replace,
|
||||
});
|
||||
},
|
||||
[
|
||||
dispatchSelectAgent,
|
||||
flushPendingDraft,
|
||||
push,
|
||||
replace,
|
||||
setInspectSidebar,
|
||||
setMobilePaneChat,
|
||||
setPersonalityHasUnsavedChanges,
|
||||
]
|
||||
);
|
||||
|
||||
const handleBackToChat = useCallback(() => {
|
||||
const needsDiscardConfirmation = shouldConfirmDiscardPersonalityChanges({
|
||||
settingsRouteActive,
|
||||
activeTab,
|
||||
personalityHasUnsavedChanges,
|
||||
});
|
||||
const discardConfirmed = needsDiscardConfirmation ? confirmDiscard() : true;
|
||||
const commands = planBackToChatCommands({
|
||||
settingsRouteActive,
|
||||
activeTab,
|
||||
personalityHasUnsavedChanges,
|
||||
discardConfirmed,
|
||||
});
|
||||
applyCommands(commands);
|
||||
}, [
|
||||
activeTab,
|
||||
applyCommands,
|
||||
confirmDiscard,
|
||||
personalityHasUnsavedChanges,
|
||||
settingsRouteActive,
|
||||
]);
|
||||
|
||||
const handleSettingsRouteTabChange = useCallback(
|
||||
(nextTab: SettingsRouteTab) => {
|
||||
const currentTab = inspectSidebar?.tab ?? "personality";
|
||||
const needsDiscardConfirmation =
|
||||
currentTab === "personality" &&
|
||||
nextTab !== "personality" &&
|
||||
shouldConfirmDiscardPersonalityChanges({
|
||||
settingsRouteActive,
|
||||
activeTab: currentTab,
|
||||
personalityHasUnsavedChanges,
|
||||
});
|
||||
const discardConfirmed = needsDiscardConfirmation ? confirmDiscard() : true;
|
||||
|
||||
const commands = planSettingsTabChangeCommands({
|
||||
nextTab,
|
||||
currentInspectSidebar: inspectSidebar,
|
||||
settingsRouteAgentId,
|
||||
settingsRouteActive,
|
||||
personalityHasUnsavedChanges,
|
||||
discardConfirmed,
|
||||
});
|
||||
applyCommands(commands);
|
||||
},
|
||||
[
|
||||
applyCommands,
|
||||
confirmDiscard,
|
||||
inspectSidebar,
|
||||
personalityHasUnsavedChanges,
|
||||
settingsRouteActive,
|
||||
settingsRouteAgentId,
|
||||
]
|
||||
);
|
||||
|
||||
const handleOpenAgentSettingsRoute = useCallback(
|
||||
(agentId: string) => {
|
||||
const commands = planOpenSettingsRouteCommands({
|
||||
agentId,
|
||||
currentInspectSidebar: inspectSidebar,
|
||||
focusedAgentId,
|
||||
});
|
||||
applyCommands(commands);
|
||||
},
|
||||
[applyCommands, focusedAgentId, inspectSidebar]
|
||||
);
|
||||
|
||||
const handleFleetSelectAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
const commands = planFleetSelectCommands({
|
||||
agentId,
|
||||
currentInspectSidebar: inspectSidebar,
|
||||
focusedAgentId,
|
||||
});
|
||||
applyCommands(commands);
|
||||
},
|
||||
[applyCommands, focusedAgentId, inspectSidebar]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const routeAgentId = (settingsRouteAgentId ?? "").trim();
|
||||
const hasRouteAgent = routeAgentId
|
||||
? agents.some((agent) => agent.agentId === routeAgentId)
|
||||
: false;
|
||||
|
||||
const commands = planSettingsRouteSyncCommands({
|
||||
settingsRouteActive,
|
||||
settingsRouteAgentId,
|
||||
status,
|
||||
agentsLoadedOnce,
|
||||
selectedAgentId,
|
||||
hasRouteAgent,
|
||||
currentInspectSidebar: inspectSidebar,
|
||||
});
|
||||
|
||||
applyCommands(commands);
|
||||
}, [
|
||||
applyCommands,
|
||||
agents,
|
||||
agentsLoadedOnce,
|
||||
inspectSidebar,
|
||||
selectedAgentId,
|
||||
settingsRouteActive,
|
||||
settingsRouteAgentId,
|
||||
status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasSelectedAgentInAgents = selectedAgentId
|
||||
? agents.some((agent) => agent.agentId === selectedAgentId)
|
||||
: false;
|
||||
const inspectSidebarAgentId = inspectSidebar?.agentId;
|
||||
const hasInspectSidebarAgent = inspectSidebarAgentId
|
||||
? agents.some((agent) => agent.agentId === inspectSidebarAgentId)
|
||||
: false;
|
||||
|
||||
const commands = planNonRouteSelectionSyncCommands({
|
||||
settingsRouteActive,
|
||||
selectedAgentId,
|
||||
focusedAgentId,
|
||||
hasSelectedAgentInAgents,
|
||||
currentInspectSidebar: inspectSidebar,
|
||||
hasInspectSidebarAgent,
|
||||
});
|
||||
|
||||
applyCommands(commands);
|
||||
}, [
|
||||
applyCommands,
|
||||
agents,
|
||||
focusedAgentId,
|
||||
inspectSidebar,
|
||||
selectedAgentId,
|
||||
settingsRouteActive,
|
||||
]);
|
||||
|
||||
return {
|
||||
handleBackToChat,
|
||||
handleSettingsRouteTabChange,
|
||||
handleOpenAgentSettingsRoute,
|
||||
handleFleetSelectAgent,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
import { resolveExecApprovalEventEffects, type ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { parseAgentIdFromSessionKey, type EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export type CronTranscriptIntent = {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
dedupeKey: string;
|
||||
line: string;
|
||||
timestampMs: number;
|
||||
activityAtMs: number | null;
|
||||
};
|
||||
|
||||
export type GatewayEventIngressDecision = {
|
||||
approvalEffects: ExecApprovalEventEffects | null;
|
||||
cronDedupeKeyToRecord: string | null;
|
||||
cronTranscriptIntent: CronTranscriptIntent | null;
|
||||
};
|
||||
|
||||
const NO_CRON_DECISION = {
|
||||
cronDedupeKeyToRecord: null,
|
||||
cronTranscriptIntent: null,
|
||||
} as const;
|
||||
|
||||
const resolveCronDecision = (params: {
|
||||
event: EventFrame;
|
||||
agents: AgentState[];
|
||||
seenCronDedupeKeys: ReadonlySet<string>;
|
||||
nowMs: number;
|
||||
}): Pick<GatewayEventIngressDecision, "cronDedupeKeyToRecord" | "cronTranscriptIntent"> => {
|
||||
if (params.event.event !== "cron") {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const payload = params.event.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (record.action !== "finished") {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const sessionKey = typeof record.sessionKey === "string" ? record.sessionKey.trim() : "";
|
||||
if (!sessionKey) {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const agentId = parseAgentIdFromSessionKey(sessionKey);
|
||||
if (!agentId) {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const jobId = typeof record.jobId === "string" ? record.jobId.trim() : "";
|
||||
if (!jobId) {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
const sessionId = typeof record.sessionId === "string" ? record.sessionId.trim() : "";
|
||||
const runAtMs = typeof record.runAtMs === "number" ? record.runAtMs : null;
|
||||
const status = typeof record.status === "string" ? record.status.trim() : "";
|
||||
const error = typeof record.error === "string" ? record.error.trim() : "";
|
||||
const summary = typeof record.summary === "string" ? record.summary.trim() : "";
|
||||
|
||||
const dedupeKey = `cron:${jobId}:${sessionId || (runAtMs ?? "none")}`;
|
||||
if (params.seenCronDedupeKeys.has(dedupeKey)) {
|
||||
return NO_CRON_DECISION;
|
||||
}
|
||||
|
||||
const agent = params.agents.find((entry) => entry.agentId === agentId) ?? null;
|
||||
if (!agent) {
|
||||
return {
|
||||
cronDedupeKeyToRecord: dedupeKey,
|
||||
cronTranscriptIntent: null,
|
||||
};
|
||||
}
|
||||
|
||||
const header = `Cron finished (${status || "unknown"}): ${jobId}`;
|
||||
const body = summary || error || "(no output)";
|
||||
return {
|
||||
cronDedupeKeyToRecord: dedupeKey,
|
||||
cronTranscriptIntent: {
|
||||
agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
dedupeKey,
|
||||
line: `${header}\n\n${body}`,
|
||||
timestampMs: runAtMs ?? params.nowMs,
|
||||
activityAtMs: runAtMs,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveGatewayEventIngressDecision = (params: {
|
||||
event: EventFrame;
|
||||
agents: AgentState[];
|
||||
seenCronDedupeKeys: ReadonlySet<string>;
|
||||
nowMs: number;
|
||||
}): GatewayEventIngressDecision => {
|
||||
const approvalEffects = resolveExecApprovalEventEffects({
|
||||
event: params.event,
|
||||
agents: params.agents,
|
||||
});
|
||||
return {
|
||||
approvalEffects,
|
||||
...resolveCronDecision(params),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,480 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { logTranscriptDebugMetric } from "@/features/agents/state/transcript";
|
||||
import {
|
||||
classifyGatewayEventKind,
|
||||
getChatSummaryPatch,
|
||||
resolveAssistantCompletionTimestamp,
|
||||
type AgentEventPayload,
|
||||
type ChatEventPayload,
|
||||
} from "@/features/agents/state/runtimeEventBridge";
|
||||
import { decideSummaryRefreshEvent } from "@/features/agents/state/runtimeEventPolicy";
|
||||
import { isClosedRun } from "@/features/agents/state/runtimeTerminalWorkflow";
|
||||
import {
|
||||
createRuntimeEventCoordinatorState,
|
||||
markChatRunSeen,
|
||||
pruneRuntimeEventCoordinatorState,
|
||||
reduceClearRunTracking,
|
||||
reduceLifecycleFallbackFired,
|
||||
reduceMarkActivityThrottled,
|
||||
reduceRuntimeAgentWorkflowCommands,
|
||||
reduceRuntimeChatWorkflowCommands,
|
||||
reduceRuntimePolicyIntents,
|
||||
type RuntimeCoordinatorDispatchAction,
|
||||
type RuntimeCoordinatorEffectCommand,
|
||||
} from "@/features/agents/state/runtimeEventCoordinatorWorkflow";
|
||||
import {
|
||||
type EventFrame,
|
||||
isSameSessionKey,
|
||||
parseAgentIdFromSessionKey,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
import {
|
||||
extractText,
|
||||
extractThinking,
|
||||
extractToolLines,
|
||||
isTraceMarkdown,
|
||||
stripUiMetadata,
|
||||
} from "@/lib/text/message-extract";
|
||||
import { planRuntimeChatEvent } from "@/features/agents/state/runtimeChatEventWorkflow";
|
||||
import { planRuntimeAgentEvent } from "@/features/agents/state/runtimeAgentEventWorkflow";
|
||||
|
||||
// This module is the runtime event orchestrator. It keeps one gateway intake path, maps
|
||||
// transport-specific session keys back to agents, delegates stream-specific planning to the
|
||||
// workflow modules, and executes the coordinator's dispatch/effect commands.
|
||||
export type GatewayRuntimeEventHandlerDeps = {
|
||||
getStatus: () => "disconnected" | "connecting" | "connected";
|
||||
getAgents: () => AgentState[];
|
||||
dispatch: (action: RuntimeCoordinatorDispatchAction) => void;
|
||||
queueLivePatch: (agentId: string, patch: Partial<AgentState>) => void;
|
||||
clearPendingLivePatch: (agentId: string) => void;
|
||||
now?: () => number;
|
||||
|
||||
loadSummarySnapshot: () => Promise<void>;
|
||||
requestHistoryRefresh: (command: {
|
||||
agentId: string;
|
||||
reason: "chat-final-no-trace" | "run-start-no-chat";
|
||||
sessionKey?: string;
|
||||
}) => Promise<void> | void;
|
||||
refreshHeartbeatLatestUpdate: () => void;
|
||||
bumpHeartbeatTick: () => void;
|
||||
|
||||
setTimeout: (fn: () => void, delayMs: number) => number;
|
||||
clearTimeout: (id: number) => void;
|
||||
|
||||
isDisconnectLikeError: (err: unknown) => boolean;
|
||||
logWarn?: (message: string, meta?: unknown) => void;
|
||||
shouldSuppressRunAbortedLine?: (params: {
|
||||
agentId: string;
|
||||
runId: string | null;
|
||||
sessionKey: string;
|
||||
stopReason: string | null;
|
||||
}) => boolean;
|
||||
|
||||
updateSpecialLatestUpdate: (agentId: string, agent: AgentState, message: string) => void;
|
||||
};
|
||||
|
||||
export type GatewayRuntimeEventHandler = {
|
||||
handleEvent: (event: EventFrame) => void;
|
||||
clearRunTracking: (runId?: string | null) => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
const findAgentBySessionKey = (agents: AgentState[], sessionKey: string): string | null => {
|
||||
const exact = agents.find((agent) => isSameSessionKey(agent.sessionKey, sessionKey));
|
||||
if (exact) return exact.agentId;
|
||||
// Transport sessions such as Telegram or WhatsApp can extend the canonical main session key.
|
||||
// Falling back to the parsed agent id keeps runtime recovery transport-agnostic.
|
||||
const parsedAgentId = parseAgentIdFromSessionKey(sessionKey);
|
||||
if (!parsedAgentId) return null;
|
||||
return agents.some((agent) => agent.agentId === parsedAgentId) ? parsedAgentId : null;
|
||||
};
|
||||
|
||||
const findAgentByRunId = (agents: AgentState[], runId: string): string | null => {
|
||||
const match = agents.find((agent) => agent.runId === runId);
|
||||
return match ? match.agentId : null;
|
||||
};
|
||||
|
||||
const resolveRole = (message: unknown) =>
|
||||
message && typeof message === "object"
|
||||
? (message as Record<string, unknown>).role
|
||||
: null;
|
||||
|
||||
export function createGatewayRuntimeEventHandler(
|
||||
deps: GatewayRuntimeEventHandlerDeps
|
||||
): GatewayRuntimeEventHandler {
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const CLOSED_RUN_TTL_MS = 30_000;
|
||||
const LIFECYCLE_FALLBACK_DELAY_MS = 0;
|
||||
|
||||
let coordinatorState = createRuntimeEventCoordinatorState();
|
||||
|
||||
const lifecycleFallbackTimerIdByRun = new Map<string, number>();
|
||||
let summaryRefreshTimer: number | null = null;
|
||||
|
||||
const toRunId = (runId?: string | null): string => runId?.trim() ?? "";
|
||||
|
||||
const logWarn =
|
||||
deps.logWarn ??
|
||||
((message: string, meta?: unknown) => {
|
||||
console.warn(message, meta);
|
||||
});
|
||||
|
||||
const cancelLifecycleFallback = (runId?: string | null) => {
|
||||
const key = toRunId(runId);
|
||||
if (!key) return;
|
||||
const timerId = lifecycleFallbackTimerIdByRun.get(key);
|
||||
if (typeof timerId !== "number") return;
|
||||
deps.clearTimeout(timerId);
|
||||
lifecycleFallbackTimerIdByRun.delete(key);
|
||||
};
|
||||
|
||||
const executeCoordinatorEffects = (effects: RuntimeCoordinatorEffectCommand[]) => {
|
||||
for (const effect of effects) {
|
||||
if (effect.kind === "dispatch") {
|
||||
deps.dispatch(effect.action);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "queueLivePatch") {
|
||||
deps.queueLivePatch(effect.agentId, effect.patch);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "clearPendingLivePatch") {
|
||||
deps.clearPendingLivePatch(effect.agentId);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "requestHistoryRefresh") {
|
||||
deps.setTimeout(() => {
|
||||
void deps.requestHistoryRefresh({
|
||||
agentId: effect.agentId,
|
||||
reason: effect.reason,
|
||||
sessionKey: effect.sessionKey,
|
||||
});
|
||||
}, effect.deferMs);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "scheduleSummaryRefresh") {
|
||||
if (effect.includeHeartbeatRefresh) {
|
||||
deps.bumpHeartbeatTick();
|
||||
deps.refreshHeartbeatLatestUpdate();
|
||||
}
|
||||
if (summaryRefreshTimer !== null) {
|
||||
deps.clearTimeout(summaryRefreshTimer);
|
||||
}
|
||||
summaryRefreshTimer = deps.setTimeout(() => {
|
||||
summaryRefreshTimer = null;
|
||||
void deps.loadSummarySnapshot();
|
||||
}, effect.delayMs);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "cancelLifecycleFallback") {
|
||||
cancelLifecycleFallback(effect.runId);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "scheduleLifecycleFallback") {
|
||||
const fallbackTimerId = deps.setTimeout(() => {
|
||||
lifecycleFallbackTimerIdByRun.delete(effect.runId);
|
||||
const fallbackReduced = reduceLifecycleFallbackFired({
|
||||
state: coordinatorState,
|
||||
runId: effect.runId,
|
||||
agentId: effect.agentId,
|
||||
sessionKey: effect.sessionKey,
|
||||
finalText: effect.finalText,
|
||||
transitionPatch: effect.transitionPatch,
|
||||
nowMs: now(),
|
||||
options: { closedRunTtlMs: CLOSED_RUN_TTL_MS },
|
||||
});
|
||||
coordinatorState = fallbackReduced.state;
|
||||
executeCoordinatorEffects(fallbackReduced.effects);
|
||||
}, effect.delayMs);
|
||||
lifecycleFallbackTimerIdByRun.set(effect.runId, fallbackTimerId);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "appendAbortedIfNotSuppressed") {
|
||||
const suppressAbortedLine =
|
||||
deps.shouldSuppressRunAbortedLine?.({
|
||||
agentId: effect.agentId,
|
||||
runId: effect.runId,
|
||||
sessionKey: effect.sessionKey,
|
||||
stopReason: effect.stopReason,
|
||||
}) ?? false;
|
||||
if (!suppressAbortedLine) {
|
||||
deps.dispatch({
|
||||
type: "appendOutput",
|
||||
agentId: effect.agentId,
|
||||
line: "Run aborted.",
|
||||
transcript: {
|
||||
source: "runtime-chat",
|
||||
runId: effect.runId,
|
||||
sessionKey: effect.sessionKey,
|
||||
timestampMs: effect.timestampMs,
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "logMetric") {
|
||||
logTranscriptDebugMetric(effect.metric, effect.meta);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "logWarn") {
|
||||
logWarn(effect.message, effect.meta);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "updateSpecialLatest") {
|
||||
const agent =
|
||||
effect.agentSnapshot?.agentId === effect.agentId
|
||||
? effect.agentSnapshot
|
||||
: deps.getAgents().find((entry) => entry.agentId === effect.agentId);
|
||||
if (agent) {
|
||||
void deps.updateSpecialLatestUpdate(effect.agentId, agent, effect.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearRunTracking = (runId?: string | null) => {
|
||||
const cleared = reduceClearRunTracking({
|
||||
state: coordinatorState,
|
||||
runId,
|
||||
});
|
||||
coordinatorState = cleared.state;
|
||||
executeCoordinatorEffects(cleared.effects);
|
||||
};
|
||||
|
||||
const pruneCoordinatorState = (at: number = now()) => {
|
||||
const pruned = pruneRuntimeEventCoordinatorState({
|
||||
state: coordinatorState,
|
||||
at,
|
||||
});
|
||||
coordinatorState = pruned.state;
|
||||
executeCoordinatorEffects(pruned.effects);
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (summaryRefreshTimer !== null) {
|
||||
deps.clearTimeout(summaryRefreshTimer);
|
||||
summaryRefreshTimer = null;
|
||||
}
|
||||
for (const timerId of lifecycleFallbackTimerIdByRun.values()) {
|
||||
deps.clearTimeout(timerId);
|
||||
}
|
||||
lifecycleFallbackTimerIdByRun.clear();
|
||||
coordinatorState = createRuntimeEventCoordinatorState();
|
||||
};
|
||||
|
||||
const handleRuntimeChatEvent = (payload: ChatEventPayload) => {
|
||||
if (!payload.sessionKey) return;
|
||||
pruneCoordinatorState();
|
||||
|
||||
if (
|
||||
payload.runId &&
|
||||
payload.state === "delta" &&
|
||||
isClosedRun(coordinatorState.runtimeTerminalState, payload.runId)
|
||||
) {
|
||||
logTranscriptDebugMetric("late_event_ignored_closed_run", {
|
||||
stream: "chat",
|
||||
state: payload.state,
|
||||
runId: payload.runId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
coordinatorState = markChatRunSeen(coordinatorState, payload.runId);
|
||||
|
||||
const agentsSnapshot = deps.getAgents();
|
||||
const agentId = findAgentBySessionKey(agentsSnapshot, payload.sessionKey);
|
||||
if (!agentId) return;
|
||||
const agent = agentsSnapshot.find((entry) => entry.agentId === agentId);
|
||||
const activeRunId = agent?.runId?.trim() ?? "";
|
||||
const role = resolveRole(payload.message);
|
||||
const nowMs = now();
|
||||
|
||||
if (payload.runId && activeRunId && activeRunId !== payload.runId) {
|
||||
clearRunTracking(payload.runId);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!activeRunId &&
|
||||
agent?.status !== "running" &&
|
||||
payload.state === "delta" &&
|
||||
role !== "user" &&
|
||||
role !== "system"
|
||||
) {
|
||||
clearRunTracking(payload.runId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
const summaryPatch = getChatSummaryPatch(payload, nowMs);
|
||||
if (summaryPatch) {
|
||||
deps.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: {
|
||||
...summaryPatch,
|
||||
sessionCreated: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (role === "user" || role === "system") {
|
||||
return;
|
||||
}
|
||||
|
||||
const activityReduced = reduceMarkActivityThrottled({
|
||||
state: coordinatorState,
|
||||
agentId,
|
||||
at: nowMs,
|
||||
});
|
||||
coordinatorState = activityReduced.state;
|
||||
executeCoordinatorEffects(activityReduced.effects);
|
||||
|
||||
const nextTextRaw = extractText(payload.message);
|
||||
const nextText = nextTextRaw ? stripUiMetadata(nextTextRaw) : null;
|
||||
const nextThinking = extractThinking(payload.message ?? payload);
|
||||
const toolLines = extractToolLines(payload.message ?? payload);
|
||||
const isToolRole = role === "tool" || role === "toolResult";
|
||||
const assistantCompletionAt = resolveAssistantCompletionTimestamp({
|
||||
role,
|
||||
state: payload.state,
|
||||
message: payload.message,
|
||||
now: now(),
|
||||
});
|
||||
const normalizedAssistantFinalText =
|
||||
payload.state === "final" &&
|
||||
role === "assistant" &&
|
||||
!isToolRole &&
|
||||
typeof nextText === "string"
|
||||
? normalizeAssistantDisplayText(nextText)
|
||||
: null;
|
||||
const finalAssistantText =
|
||||
normalizedAssistantFinalText && normalizedAssistantFinalText.length > 0
|
||||
? normalizedAssistantFinalText
|
||||
: null;
|
||||
|
||||
const chatWorkflow = planRuntimeChatEvent({
|
||||
payload,
|
||||
agentId,
|
||||
agent,
|
||||
activeRunId: activeRunId || null,
|
||||
runtimeTerminalState: coordinatorState.runtimeTerminalState,
|
||||
role,
|
||||
nowMs,
|
||||
nextTextRaw,
|
||||
nextText,
|
||||
nextThinking,
|
||||
toolLines,
|
||||
isToolRole,
|
||||
assistantCompletionAt,
|
||||
finalAssistantText,
|
||||
hasThinkingStarted: payload.runId
|
||||
? coordinatorState.thinkingStartedAtByRun.has(payload.runId)
|
||||
: false,
|
||||
hasTraceInOutput:
|
||||
agent?.outputLines.some((line) => isTraceMarkdown(line.trim())) ?? false,
|
||||
isThinkingDebugSessionSeen: coordinatorState.thinkingDebugBySession.has(
|
||||
payload.sessionKey
|
||||
),
|
||||
thinkingStartedAtMs: payload.runId
|
||||
? (coordinatorState.thinkingStartedAtByRun.get(payload.runId) ?? null)
|
||||
: null,
|
||||
});
|
||||
|
||||
const reduced = reduceRuntimeChatWorkflowCommands({
|
||||
state: coordinatorState,
|
||||
payload,
|
||||
agentId,
|
||||
agent,
|
||||
commands: chatWorkflow.commands,
|
||||
nowMs,
|
||||
options: { closedRunTtlMs: CLOSED_RUN_TTL_MS },
|
||||
});
|
||||
coordinatorState = reduced.state;
|
||||
executeCoordinatorEffects(reduced.effects);
|
||||
};
|
||||
|
||||
const handleRuntimeAgentEvent = (payload: AgentEventPayload) => {
|
||||
if (!payload.runId) return;
|
||||
pruneCoordinatorState();
|
||||
|
||||
const agentsSnapshot = deps.getAgents();
|
||||
const directMatch = payload.sessionKey
|
||||
? findAgentBySessionKey(agentsSnapshot, payload.sessionKey)
|
||||
: null;
|
||||
const agentId = directMatch ?? findAgentByRunId(agentsSnapshot, payload.runId);
|
||||
if (!agentId) return;
|
||||
const agent = agentsSnapshot.find((entry) => entry.agentId === agentId);
|
||||
if (!agent) return;
|
||||
|
||||
const nowMs = now();
|
||||
const agentWorkflow = planRuntimeAgentEvent({
|
||||
payload,
|
||||
agent,
|
||||
activeRunId: agent.runId?.trim() || null,
|
||||
nowMs,
|
||||
runtimeTerminalState: coordinatorState.runtimeTerminalState,
|
||||
hasChatEvents: coordinatorState.chatRunSeen.has(payload.runId),
|
||||
hasPendingFallbackTimer: lifecycleFallbackTimerIdByRun.has(
|
||||
toRunId(payload.runId)
|
||||
),
|
||||
previousThinkingRaw: coordinatorState.thinkingStreamByRun.get(payload.runId) ?? null,
|
||||
previousAssistantRaw:
|
||||
coordinatorState.assistantStreamByRun.get(payload.runId) ?? null,
|
||||
thinkingStartedAtMs:
|
||||
coordinatorState.thinkingStartedAtByRun.get(payload.runId) ?? null,
|
||||
historyRefreshRequested:
|
||||
coordinatorState.historyRefreshRequestedByRun.has(payload.runId),
|
||||
lifecycleFallbackDelayMs: LIFECYCLE_FALLBACK_DELAY_MS,
|
||||
});
|
||||
|
||||
const reduced = reduceRuntimeAgentWorkflowCommands({
|
||||
state: coordinatorState,
|
||||
payload,
|
||||
agentId,
|
||||
agent,
|
||||
commands: agentWorkflow.commands,
|
||||
nowMs,
|
||||
options: { closedRunTtlMs: CLOSED_RUN_TTL_MS },
|
||||
});
|
||||
coordinatorState = reduced.state;
|
||||
executeCoordinatorEffects(reduced.effects);
|
||||
};
|
||||
|
||||
const handleEvent = (event: EventFrame) => {
|
||||
const eventKind = classifyGatewayEventKind(event.event);
|
||||
|
||||
// Summary refresh events share the same intake path, but they bypass stream planners and
|
||||
// go straight through policy intents because they do not carry transcript-bearing runtime data.
|
||||
if (eventKind === "summary-refresh") {
|
||||
const summaryIntents = decideSummaryRefreshEvent({
|
||||
event: event.event,
|
||||
status: deps.getStatus(),
|
||||
});
|
||||
const reduced = reduceRuntimePolicyIntents({
|
||||
state: coordinatorState,
|
||||
intents: summaryIntents,
|
||||
nowMs: now(),
|
||||
options: { closedRunTtlMs: CLOSED_RUN_TTL_MS },
|
||||
});
|
||||
coordinatorState = reduced.state;
|
||||
executeCoordinatorEffects(reduced.effects);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventKind === "runtime-chat") {
|
||||
const payload = event.payload as ChatEventPayload | undefined;
|
||||
if (!payload) return;
|
||||
handleRuntimeChatEvent(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventKind === "runtime-agent") {
|
||||
const payload = event.payload as AgentEventPayload | undefined;
|
||||
if (!payload) return;
|
||||
handleRuntimeAgentEvent(payload);
|
||||
}
|
||||
};
|
||||
|
||||
return { handleEvent, clearRunTracking, dispose };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
const normalizedRunId = (value: unknown): string => {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
};
|
||||
|
||||
export const mergePendingLivePatch = (
|
||||
existing: Partial<AgentState> | undefined,
|
||||
incoming: Partial<AgentState>
|
||||
): Partial<AgentState> => {
|
||||
if (!existing) return incoming;
|
||||
|
||||
const existingRunId = normalizedRunId(existing.runId);
|
||||
const incomingRunId = normalizedRunId(incoming.runId);
|
||||
|
||||
if (incomingRunId && existingRunId && incomingRunId !== existingRunId) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
if (incomingRunId && !existingRunId) {
|
||||
const rest = { ...existing };
|
||||
delete rest.streamText;
|
||||
delete rest.thinkingTrace;
|
||||
return { ...rest, ...incoming };
|
||||
}
|
||||
|
||||
return { ...existing, ...incoming };
|
||||
};
|
||||
@@ -0,0 +1,445 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import {
|
||||
getAgentSummaryPatch,
|
||||
isReasoningRuntimeAgentStream,
|
||||
mergeRuntimeStream,
|
||||
resolveLifecyclePatch,
|
||||
shouldPublishAssistantStream,
|
||||
type AgentEventPayload,
|
||||
} from "@/features/agents/state/runtimeEventBridge";
|
||||
import {
|
||||
decideRuntimeAgentEvent,
|
||||
type RuntimePolicyIntent,
|
||||
} from "@/features/agents/state/runtimeEventPolicy";
|
||||
import {
|
||||
deriveLifecycleTerminalDecision,
|
||||
isClosedRun,
|
||||
type LifecycleTerminalDecision,
|
||||
type RuntimeTerminalState,
|
||||
} from "@/features/agents/state/runtimeTerminalWorkflow";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
import {
|
||||
extractText,
|
||||
extractThinking,
|
||||
extractThinkingFromTaggedStream,
|
||||
extractToolLines,
|
||||
formatToolCallMarkdown,
|
||||
isUiMetadataPrefix,
|
||||
stripUiMetadata,
|
||||
} from "@/lib/text/message-extract";
|
||||
|
||||
export type RuntimeAgentWorkflowCommand =
|
||||
| { kind: "applyPolicyIntents"; intents: RuntimePolicyIntent[] }
|
||||
| { kind: "logMetric"; metric: string; meta: Record<string, unknown> }
|
||||
| { kind: "markActivity"; at: number }
|
||||
| { kind: "setThinkingStreamRaw"; runId: string; raw: string }
|
||||
| { kind: "setAssistantStreamRaw"; runId: string; raw: string }
|
||||
| { kind: "markThinkingStarted"; runId: string; at: number }
|
||||
| { kind: "queueAgentPatch"; patch: Partial<AgentState> }
|
||||
| { kind: "appendToolLines"; lines: string[]; timestampMs: number }
|
||||
| { kind: "markHistoryRefreshRequested"; runId: string }
|
||||
| {
|
||||
kind: "scheduleHistoryRefresh";
|
||||
delayMs: number;
|
||||
reason: "chat-final-no-trace" | "run-start-no-chat";
|
||||
}
|
||||
| {
|
||||
kind: "applyLifecycleDecision";
|
||||
decision: LifecycleTerminalDecision;
|
||||
transitionPatch: Partial<AgentState>;
|
||||
shouldClearPendingLivePatch: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeAgentWorkflowInput = {
|
||||
payload: AgentEventPayload;
|
||||
agent: AgentState;
|
||||
activeRunId: string | null;
|
||||
nowMs: number;
|
||||
runtimeTerminalState: RuntimeTerminalState;
|
||||
hasChatEvents: boolean;
|
||||
hasPendingFallbackTimer: boolean;
|
||||
previousThinkingRaw: string | null;
|
||||
previousAssistantRaw: string | null;
|
||||
thinkingStartedAtMs: number | null;
|
||||
historyRefreshRequested: boolean;
|
||||
lifecycleFallbackDelayMs: number;
|
||||
};
|
||||
|
||||
export type RuntimeAgentWorkflowResult = {
|
||||
commands: RuntimeAgentWorkflowCommand[];
|
||||
};
|
||||
|
||||
const extractReasoningBody = (value: string): string | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const match = trimmed.match(/^reasoning:\s*([\s\S]*)$/i);
|
||||
if (!match) return null;
|
||||
const body = (match[1] ?? "").trim();
|
||||
return body || null;
|
||||
};
|
||||
|
||||
const normalizeReasoningComparable = (value: string): string =>
|
||||
normalizeAssistantDisplayText(value).trim().toLowerCase();
|
||||
|
||||
const hasUnclosedThinkingTag = (value: string): boolean => {
|
||||
const openMatches = [
|
||||
...value.matchAll(/<\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi),
|
||||
];
|
||||
if (openMatches.length === 0) return false;
|
||||
const closeMatches = [
|
||||
...value.matchAll(/<\s*\/\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi),
|
||||
];
|
||||
const lastOpen = openMatches[openMatches.length - 1];
|
||||
const lastClose = closeMatches[closeMatches.length - 1];
|
||||
if (!lastOpen) return false;
|
||||
if (!lastClose) return true;
|
||||
return (lastClose.index ?? -1) < (lastOpen.index ?? -1);
|
||||
};
|
||||
|
||||
const hasReasoningSignal = ({
|
||||
rawText,
|
||||
rawDelta,
|
||||
mergedRaw,
|
||||
}: {
|
||||
rawText: string;
|
||||
rawDelta: string;
|
||||
mergedRaw: string;
|
||||
}): boolean => {
|
||||
if (hasUnclosedThinkingTag(mergedRaw)) return true;
|
||||
return Boolean(extractReasoningBody(rawText) ?? extractReasoningBody(rawDelta));
|
||||
};
|
||||
|
||||
const isReasoningOnlyAssistantChunk = ({
|
||||
rawText,
|
||||
rawDelta,
|
||||
mergedRaw,
|
||||
cleaned,
|
||||
liveThinking,
|
||||
}: {
|
||||
rawText: string;
|
||||
rawDelta: string;
|
||||
mergedRaw: string;
|
||||
cleaned: string;
|
||||
liveThinking: string | null;
|
||||
}): boolean => {
|
||||
if (!liveThinking) return false;
|
||||
const normalizedCleaned = normalizeReasoningComparable(cleaned);
|
||||
const normalizedThinking = normalizeReasoningComparable(liveThinking);
|
||||
const normalizedCleanedReasoningBody = normalizeReasoningComparable(
|
||||
extractReasoningBody(cleaned) ?? ""
|
||||
);
|
||||
const cleanedMatchesReasoning =
|
||||
!normalizedCleaned ||
|
||||
normalizedCleaned === normalizedThinking ||
|
||||
(normalizedCleanedReasoningBody.length > 0 &&
|
||||
normalizedCleanedReasoningBody === normalizedThinking);
|
||||
if (!cleanedMatchesReasoning) return false;
|
||||
return hasReasoningSignal({ rawText, rawDelta, mergedRaw });
|
||||
};
|
||||
|
||||
const resolveThinkingFromAgentStream = (
|
||||
data: Record<string, unknown> | null,
|
||||
rawStream: string,
|
||||
opts?: { treatPlainTextAsThinking?: boolean }
|
||||
): string | null => {
|
||||
if (data) {
|
||||
const extracted = extractThinking(data);
|
||||
if (extracted) return extracted;
|
||||
const text = typeof data.text === "string" ? data.text : "";
|
||||
const delta = typeof data.delta === "string" ? data.delta : "";
|
||||
const prefixed = extractReasoningBody(text) ?? extractReasoningBody(delta);
|
||||
if (prefixed) return prefixed;
|
||||
if (opts?.treatPlainTextAsThinking) {
|
||||
const cleanedDelta = delta.trim();
|
||||
if (cleanedDelta) return cleanedDelta;
|
||||
const cleanedText = text.trim();
|
||||
if (cleanedText) return cleanedText;
|
||||
}
|
||||
}
|
||||
const tagged = extractThinkingFromTaggedStream(rawStream);
|
||||
return tagged || null;
|
||||
};
|
||||
|
||||
export const planRuntimeAgentEvent = (
|
||||
input: RuntimeAgentWorkflowInput
|
||||
): RuntimeAgentWorkflowResult => {
|
||||
const commands: RuntimeAgentWorkflowCommand[] = [];
|
||||
const {
|
||||
payload,
|
||||
agent,
|
||||
activeRunId,
|
||||
nowMs,
|
||||
runtimeTerminalState,
|
||||
hasChatEvents,
|
||||
hasPendingFallbackTimer,
|
||||
previousThinkingRaw,
|
||||
previousAssistantRaw,
|
||||
thinkingStartedAtMs,
|
||||
historyRefreshRequested,
|
||||
lifecycleFallbackDelayMs,
|
||||
} = input;
|
||||
const runId = payload.runId?.trim() ?? "";
|
||||
if (!runId) return { commands };
|
||||
const stream = typeof payload.stream === "string" ? payload.stream : "";
|
||||
const data =
|
||||
payload.data && typeof payload.data === "object"
|
||||
? (payload.data as Record<string, unknown>)
|
||||
: null;
|
||||
const phase = typeof data?.phase === "string" ? data.phase : "";
|
||||
|
||||
const preflightIntents = decideRuntimeAgentEvent({
|
||||
runId,
|
||||
stream,
|
||||
phase,
|
||||
activeRunId,
|
||||
agentStatus: agent.status,
|
||||
isClosedRun: isClosedRun(runtimeTerminalState, runId),
|
||||
});
|
||||
const hasOnlyPreflightCleanup =
|
||||
preflightIntents.length > 0 &&
|
||||
preflightIntents.every((intent) => intent.kind === "clearRunTracking");
|
||||
if (hasOnlyPreflightCleanup) {
|
||||
commands.push({ kind: "applyPolicyIntents", intents: preflightIntents });
|
||||
return { commands };
|
||||
}
|
||||
if (preflightIntents.some((intent) => intent.kind === "ignore")) {
|
||||
if (
|
||||
preflightIntents.some(
|
||||
(intent) =>
|
||||
intent.kind === "ignore" && intent.reason === "closed-run-event"
|
||||
)
|
||||
) {
|
||||
commands.push({
|
||||
kind: "logMetric",
|
||||
metric: "late_event_ignored_closed_run",
|
||||
meta: {
|
||||
stream: payload.stream,
|
||||
runId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { commands };
|
||||
}
|
||||
|
||||
commands.push({ kind: "markActivity", at: nowMs });
|
||||
|
||||
if (isReasoningRuntimeAgentStream(stream)) {
|
||||
const rawText = typeof data?.text === "string" ? data.text : "";
|
||||
const rawDelta = typeof data?.delta === "string" ? data.delta : "";
|
||||
const previousRaw = previousThinkingRaw ?? "";
|
||||
let mergedRaw = previousRaw;
|
||||
if (rawText) {
|
||||
mergedRaw = rawText;
|
||||
} else if (rawDelta) {
|
||||
mergedRaw = mergeRuntimeStream(previousRaw, rawDelta);
|
||||
}
|
||||
if (mergedRaw) {
|
||||
commands.push({ kind: "setThinkingStreamRaw", runId, raw: mergedRaw });
|
||||
}
|
||||
const liveThinking =
|
||||
resolveThinkingFromAgentStream(data, mergedRaw, {
|
||||
treatPlainTextAsThinking: true,
|
||||
}) ?? (mergedRaw.trim() ? mergedRaw.trim() : null);
|
||||
if (liveThinking) {
|
||||
if (typeof thinkingStartedAtMs !== "number") {
|
||||
commands.push({ kind: "markThinkingStarted", runId, at: nowMs });
|
||||
}
|
||||
commands.push({
|
||||
kind: "queueAgentPatch",
|
||||
patch: {
|
||||
status: "running",
|
||||
runId,
|
||||
...(agent.runStartedAt === null ? { runStartedAt: nowMs } : {}),
|
||||
sessionCreated: true,
|
||||
lastActivityAt: nowMs,
|
||||
thinkingTrace: liveThinking,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { commands };
|
||||
}
|
||||
|
||||
if (stream === "assistant") {
|
||||
const rawText = typeof data?.text === "string" ? data.text : "";
|
||||
const rawDelta = typeof data?.delta === "string" ? data.delta : "";
|
||||
const previousRaw = previousAssistantRaw ?? "";
|
||||
let mergedRaw = previousRaw;
|
||||
if (rawText) {
|
||||
mergedRaw = rawText;
|
||||
} else if (rawDelta) {
|
||||
mergedRaw = mergeRuntimeStream(previousRaw, rawDelta);
|
||||
}
|
||||
if (mergedRaw) {
|
||||
commands.push({ kind: "setAssistantStreamRaw", runId, raw: mergedRaw });
|
||||
}
|
||||
|
||||
const liveThinking = resolveThinkingFromAgentStream(data, mergedRaw);
|
||||
const patch: Partial<AgentState> = {
|
||||
status: "running",
|
||||
runId,
|
||||
lastActivityAt: nowMs,
|
||||
sessionCreated: true,
|
||||
};
|
||||
if (liveThinking) {
|
||||
if (typeof thinkingStartedAtMs !== "number") {
|
||||
commands.push({ kind: "markThinkingStarted", runId, at: nowMs });
|
||||
}
|
||||
patch.thinkingTrace = liveThinking;
|
||||
}
|
||||
if (agent.runStartedAt === null) {
|
||||
patch.runStartedAt = nowMs;
|
||||
}
|
||||
if (mergedRaw && (!rawText || !isUiMetadataPrefix(rawText.trim()))) {
|
||||
const visibleText =
|
||||
extractText({ role: "assistant", content: mergedRaw }) ?? mergedRaw;
|
||||
const cleaned = stripUiMetadata(visibleText);
|
||||
const reasoningOnlyChunk = isReasoningOnlyAssistantChunk({
|
||||
rawText,
|
||||
rawDelta,
|
||||
mergedRaw,
|
||||
cleaned,
|
||||
liveThinking,
|
||||
});
|
||||
if (
|
||||
cleaned &&
|
||||
!reasoningOnlyChunk &&
|
||||
shouldPublishAssistantStream({
|
||||
nextText: cleaned,
|
||||
rawText,
|
||||
hasChatEvents,
|
||||
currentStreamText: agent.streamText ?? null,
|
||||
})
|
||||
) {
|
||||
patch.streamText = cleaned;
|
||||
}
|
||||
}
|
||||
commands.push({ kind: "queueAgentPatch", patch });
|
||||
return { commands };
|
||||
}
|
||||
|
||||
if (stream === "tool") {
|
||||
const name = typeof data?.name === "string" ? data.name : "tool";
|
||||
const toolCallId =
|
||||
typeof data?.toolCallId === "string" ? data.toolCallId : "";
|
||||
if (phase && phase !== "result") {
|
||||
const args =
|
||||
(data?.arguments as unknown) ??
|
||||
(data?.args as unknown) ??
|
||||
(data?.input as unknown) ??
|
||||
(data?.parameters as unknown) ??
|
||||
null;
|
||||
const line = formatToolCallMarkdown({
|
||||
id: toolCallId || undefined,
|
||||
name,
|
||||
arguments: args,
|
||||
});
|
||||
if (line) {
|
||||
commands.push({
|
||||
kind: "appendToolLines",
|
||||
lines: [line],
|
||||
timestampMs: nowMs,
|
||||
});
|
||||
}
|
||||
return { commands };
|
||||
}
|
||||
|
||||
if (phase !== "result") {
|
||||
return { commands };
|
||||
}
|
||||
|
||||
const result = data?.result;
|
||||
const isError =
|
||||
typeof data?.isError === "boolean" ? data.isError : undefined;
|
||||
const resultRecord =
|
||||
result && typeof result === "object"
|
||||
? (result as Record<string, unknown>)
|
||||
: null;
|
||||
const details =
|
||||
resultRecord && "details" in resultRecord ? resultRecord.details : undefined;
|
||||
let content: unknown = result;
|
||||
if (resultRecord) {
|
||||
if (Array.isArray(resultRecord.content)) {
|
||||
content = resultRecord.content;
|
||||
} else if (typeof resultRecord.text === "string") {
|
||||
content = resultRecord.text;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = extractToolLines({
|
||||
role: "tool",
|
||||
toolName: name,
|
||||
toolCallId,
|
||||
isError,
|
||||
details,
|
||||
content,
|
||||
});
|
||||
if (lines.length > 0) {
|
||||
commands.push({ kind: "appendToolLines", lines, timestampMs: nowMs });
|
||||
}
|
||||
|
||||
if (agent.showThinkingTraces && !historyRefreshRequested) {
|
||||
commands.push({ kind: "markHistoryRefreshRequested", runId });
|
||||
commands.push({
|
||||
kind: "scheduleHistoryRefresh",
|
||||
delayMs: 750,
|
||||
reason: "chat-final-no-trace",
|
||||
});
|
||||
}
|
||||
return { commands };
|
||||
}
|
||||
|
||||
if (stream !== "lifecycle") {
|
||||
return { commands };
|
||||
}
|
||||
|
||||
const summaryPatch = getAgentSummaryPatch(payload, nowMs);
|
||||
if (!summaryPatch) {
|
||||
return { commands };
|
||||
}
|
||||
if (phase !== "start" && phase !== "end" && phase !== "error") {
|
||||
return { commands };
|
||||
}
|
||||
|
||||
const transition = resolveLifecyclePatch({
|
||||
phase,
|
||||
incomingRunId: runId,
|
||||
currentRunId: agent.runId,
|
||||
lastActivityAt: summaryPatch.lastActivityAt ?? nowMs,
|
||||
});
|
||||
if (transition.kind === "ignore") {
|
||||
return { commands };
|
||||
}
|
||||
|
||||
if (phase === "start" && !hasChatEvents && !historyRefreshRequested) {
|
||||
commands.push({ kind: "markHistoryRefreshRequested", runId });
|
||||
commands.push({
|
||||
kind: "scheduleHistoryRefresh",
|
||||
delayMs: 250,
|
||||
reason: "run-start-no-chat",
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedStreamText = agent.streamText
|
||||
? normalizeAssistantDisplayText(agent.streamText)
|
||||
: "";
|
||||
const lifecycleDecision = deriveLifecycleTerminalDecision({
|
||||
mode: "event",
|
||||
state: runtimeTerminalState,
|
||||
runId,
|
||||
phase,
|
||||
hasPendingFallbackTimer,
|
||||
fallbackDelayMs: lifecycleFallbackDelayMs,
|
||||
fallbackFinalText:
|
||||
normalizedStreamText.length > 0 ? normalizedStreamText : null,
|
||||
transitionClearsRunTracking: transition.clearRunTracking,
|
||||
});
|
||||
|
||||
commands.push({
|
||||
kind: "applyLifecycleDecision",
|
||||
decision: lifecycleDecision,
|
||||
transitionPatch: transition.patch,
|
||||
shouldClearPendingLivePatch: transition.kind === "terminal",
|
||||
});
|
||||
|
||||
return { commands };
|
||||
};
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { TranscriptAppendMeta } from "@/features/agents/state/transcript";
|
||||
import type { ChatEventPayload } from "@/features/agents/state/runtimeEventBridge";
|
||||
import { decideRuntimeChatEvent, type RuntimePolicyIntent } from "@/features/agents/state/runtimeEventPolicy";
|
||||
import {
|
||||
deriveChatTerminalDecision,
|
||||
type ChatTerminalDecision,
|
||||
type RuntimeTerminalState,
|
||||
} from "@/features/agents/state/runtimeTerminalWorkflow";
|
||||
import {
|
||||
formatMetaMarkdown,
|
||||
formatThinkingMarkdown,
|
||||
isUiMetadataPrefix,
|
||||
} from "@/lib/text/message-extract";
|
||||
|
||||
export type RuntimeChatWorkflowCommand =
|
||||
| { kind: "applyChatTerminalDecision"; decision: ChatTerminalDecision }
|
||||
| { kind: "applyPolicyIntents"; intents: RuntimePolicyIntent[] }
|
||||
| { kind: "appendOutput"; line: string; transcript: TranscriptAppendMeta }
|
||||
| { kind: "appendToolLines"; lines: string[]; timestampMs: number }
|
||||
| { kind: "applyTerminalCommit"; runId: string; seq: number | null }
|
||||
| { kind: "appendAbortedIfNotSuppressed"; timestampMs: number }
|
||||
| { kind: "logMetric"; metric: string; meta: Record<string, unknown> }
|
||||
| { kind: "markThinkingDebugSession"; sessionKey: string }
|
||||
| { kind: "logWarn"; message: string; meta?: unknown };
|
||||
|
||||
export type RuntimeChatWorkflowInput = {
|
||||
payload: ChatEventPayload;
|
||||
agentId: string;
|
||||
agent: AgentState | undefined;
|
||||
activeRunId: string | null;
|
||||
runtimeTerminalState: RuntimeTerminalState;
|
||||
role: unknown;
|
||||
nowMs: number;
|
||||
nextTextRaw: string | null;
|
||||
nextText: string | null;
|
||||
nextThinking: string | null;
|
||||
toolLines: string[];
|
||||
isToolRole: boolean;
|
||||
assistantCompletionAt: number | null;
|
||||
finalAssistantText: string | null;
|
||||
hasThinkingStarted: boolean;
|
||||
hasTraceInOutput: boolean;
|
||||
isThinkingDebugSessionSeen: boolean;
|
||||
thinkingStartedAtMs: number | null;
|
||||
};
|
||||
|
||||
export type RuntimeChatWorkflowResult = {
|
||||
commands: RuntimeChatWorkflowCommand[];
|
||||
};
|
||||
|
||||
const terminalAssistantMetaEntryId = (runId?: string | null) => {
|
||||
const key = runId?.trim() ?? "";
|
||||
return key ? `run:${key}:assistant:meta` : undefined;
|
||||
};
|
||||
|
||||
const terminalAssistantFinalEntryId = (runId?: string | null) => {
|
||||
const key = runId?.trim() ?? "";
|
||||
return key ? `run:${key}:assistant:final` : undefined;
|
||||
};
|
||||
|
||||
const resolveTerminalSeq = (payload: ChatEventPayload): number | null => {
|
||||
const seq = payload.seq;
|
||||
if (typeof seq !== "number" || !Number.isFinite(seq)) return null;
|
||||
return seq;
|
||||
};
|
||||
|
||||
const summarizeThinkingMessage = (message: unknown) => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return { type: typeof message };
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
const summary: Record<string, unknown> = { keys: Object.keys(record) };
|
||||
const content = record.content;
|
||||
if (Array.isArray(content)) {
|
||||
summary.contentTypes = content.map((item) => {
|
||||
if (item && typeof item === "object") {
|
||||
const entry = item as Record<string, unknown>;
|
||||
return typeof entry.type === "string" ? entry.type : "object";
|
||||
}
|
||||
return typeof item;
|
||||
});
|
||||
} else if (typeof content === "string") {
|
||||
summary.contentLength = content.length;
|
||||
}
|
||||
if (typeof record.text === "string") {
|
||||
summary.textLength = record.text.length;
|
||||
}
|
||||
for (const key of ["analysis", "reasoning", "thinking"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") {
|
||||
summary[`${key}Length`] = value.length;
|
||||
} else if (value && typeof value === "object") {
|
||||
summary[`${key}Keys`] = Object.keys(value as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
};
|
||||
|
||||
export const planRuntimeChatEvent = (
|
||||
input: RuntimeChatWorkflowInput
|
||||
): RuntimeChatWorkflowResult => {
|
||||
const commands: RuntimeChatWorkflowCommand[] = [];
|
||||
const {
|
||||
payload,
|
||||
agentId,
|
||||
agent,
|
||||
activeRunId,
|
||||
runtimeTerminalState,
|
||||
role,
|
||||
nowMs,
|
||||
nextTextRaw,
|
||||
nextText,
|
||||
nextThinking,
|
||||
toolLines,
|
||||
isToolRole,
|
||||
assistantCompletionAt,
|
||||
finalAssistantText,
|
||||
hasThinkingStarted,
|
||||
hasTraceInOutput,
|
||||
isThinkingDebugSessionSeen,
|
||||
thinkingStartedAtMs,
|
||||
} = input;
|
||||
|
||||
if (payload.state === "delta") {
|
||||
if (typeof nextTextRaw === "string" && isUiMetadataPrefix(nextTextRaw.trim())) {
|
||||
return { commands };
|
||||
}
|
||||
const deltaIntents = decideRuntimeChatEvent({
|
||||
agentId,
|
||||
state: payload.state,
|
||||
runId: payload.runId ?? null,
|
||||
role,
|
||||
activeRunId,
|
||||
agentStatus: agent?.status ?? "idle",
|
||||
now: nowMs,
|
||||
agentRunStartedAt: agent?.runStartedAt ?? null,
|
||||
nextThinking,
|
||||
nextText,
|
||||
hasThinkingStarted,
|
||||
isClosedRun: false,
|
||||
isStaleTerminal: false,
|
||||
shouldRequestHistoryRefresh: false,
|
||||
shouldUpdateLastResult: false,
|
||||
shouldSetRunIdle: false,
|
||||
shouldSetRunError: false,
|
||||
lastResultText: null,
|
||||
assistantCompletionAt: null,
|
||||
shouldQueueLatestUpdate: false,
|
||||
latestUpdateMessage: null,
|
||||
});
|
||||
const hasOnlyDeltaCleanup =
|
||||
deltaIntents.length > 0 &&
|
||||
deltaIntents.every((intent) => intent.kind === "clearRunTracking");
|
||||
if (hasOnlyDeltaCleanup) {
|
||||
commands.push({ kind: "applyPolicyIntents", intents: deltaIntents });
|
||||
return { commands };
|
||||
}
|
||||
if (deltaIntents.some((intent) => intent.kind === "ignore")) {
|
||||
return { commands };
|
||||
}
|
||||
commands.push({ kind: "applyPolicyIntents", intents: deltaIntents });
|
||||
if (toolLines.length > 0) {
|
||||
commands.push({
|
||||
kind: "appendToolLines",
|
||||
lines: toolLines,
|
||||
timestampMs: nowMs,
|
||||
});
|
||||
}
|
||||
return { commands };
|
||||
}
|
||||
|
||||
const shouldRequestHistoryRefresh =
|
||||
payload.state === "final" &&
|
||||
!nextThinking &&
|
||||
role === "assistant" &&
|
||||
Boolean(agent) &&
|
||||
!hasTraceInOutput;
|
||||
const shouldUpdateLastResult =
|
||||
payload.state === "final" && !isToolRole && typeof finalAssistantText === "string";
|
||||
const shouldQueueLatestUpdate =
|
||||
payload.state === "final" && Boolean(agent?.lastUserMessage && !agent.latestOverride);
|
||||
const terminalSeq = payload.state === "final" ? resolveTerminalSeq(payload) : null;
|
||||
const chatTerminalDecision =
|
||||
payload.state === "final"
|
||||
? deriveChatTerminalDecision({
|
||||
state: runtimeTerminalState,
|
||||
runId: payload.runId,
|
||||
isFinal: true,
|
||||
seq: terminalSeq,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (chatTerminalDecision) {
|
||||
commands.push({
|
||||
kind: "applyChatTerminalDecision",
|
||||
decision: chatTerminalDecision,
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.state === "final" && payload.runId && chatTerminalDecision?.isStaleTerminal) {
|
||||
commands.push({
|
||||
kind: "logMetric",
|
||||
metric: "stale_terminal_chat_event_ignored",
|
||||
meta: {
|
||||
runId: payload.runId,
|
||||
seq: terminalSeq,
|
||||
lastTerminalSeq: chatTerminalDecision.lastTerminalSeqBeforeFinal,
|
||||
commitSource: chatTerminalDecision.commitSourceBeforeFinal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const chatIntents = decideRuntimeChatEvent({
|
||||
agentId,
|
||||
state: payload.state,
|
||||
runId: payload.runId ?? null,
|
||||
role,
|
||||
activeRunId,
|
||||
agentStatus: agent?.status ?? "idle",
|
||||
now: nowMs,
|
||||
agentRunStartedAt: agent?.runStartedAt ?? null,
|
||||
nextThinking,
|
||||
nextText,
|
||||
hasThinkingStarted,
|
||||
isClosedRun: false,
|
||||
isStaleTerminal: chatTerminalDecision?.isStaleTerminal ?? false,
|
||||
shouldRequestHistoryRefresh,
|
||||
shouldUpdateLastResult,
|
||||
shouldSetRunIdle: Boolean(payload.runId && agent?.runId === payload.runId && payload.state !== "error"),
|
||||
shouldSetRunError: Boolean(payload.runId && agent?.runId === payload.runId && payload.state === "error"),
|
||||
lastResultText: shouldUpdateLastResult ? finalAssistantText : null,
|
||||
assistantCompletionAt: payload.state === "final" ? assistantCompletionAt : null,
|
||||
shouldQueueLatestUpdate,
|
||||
latestUpdateMessage: shouldQueueLatestUpdate ? (agent?.lastUserMessage ?? null) : null,
|
||||
});
|
||||
const hasOnlyRunCleanup =
|
||||
chatIntents.length > 0 &&
|
||||
chatIntents.every((intent) => intent.kind === "clearRunTracking");
|
||||
if (hasOnlyRunCleanup) {
|
||||
commands.push({ kind: "applyPolicyIntents", intents: chatIntents });
|
||||
return { commands };
|
||||
}
|
||||
if (chatIntents.some((intent) => intent.kind === "ignore")) {
|
||||
return { commands };
|
||||
}
|
||||
|
||||
if (payload.state === "final") {
|
||||
if (payload.runId && chatTerminalDecision?.fallbackCommittedBeforeFinal && role === "assistant" && !isToolRole) {
|
||||
commands.push({
|
||||
kind: "logMetric",
|
||||
metric: "lifecycle_fallback_replaced_by_chat_final",
|
||||
meta: {
|
||||
runId: payload.runId,
|
||||
seq: terminalSeq,
|
||||
lastTerminalSeq: chatTerminalDecision.lastTerminalSeqBeforeFinal ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!nextThinking && role === "assistant" && !isThinkingDebugSessionSeen) {
|
||||
commands.push({
|
||||
kind: "markThinkingDebugSession",
|
||||
sessionKey: payload.sessionKey,
|
||||
});
|
||||
commands.push({
|
||||
kind: "logWarn",
|
||||
message: "No thinking trace extracted from chat event.",
|
||||
meta: {
|
||||
sessionKey: payload.sessionKey,
|
||||
message: summarizeThinkingMessage(payload.message ?? payload),
|
||||
},
|
||||
});
|
||||
}
|
||||
const thinkingText = nextThinking ?? agent?.thinkingTrace ?? null;
|
||||
const thinkingLine = thinkingText ? formatThinkingMarkdown(thinkingText) : "";
|
||||
if (role === "assistant" && typeof assistantCompletionAt === "number") {
|
||||
const thinkingDurationMs =
|
||||
typeof thinkingStartedAtMs === "number"
|
||||
? Math.max(0, assistantCompletionAt - thinkingStartedAtMs)
|
||||
: null;
|
||||
commands.push({
|
||||
kind: "appendOutput",
|
||||
line: formatMetaMarkdown({
|
||||
role: "assistant",
|
||||
timestamp: assistantCompletionAt,
|
||||
thinkingDurationMs,
|
||||
}),
|
||||
transcript: {
|
||||
source: "runtime-chat",
|
||||
runId: payload.runId ?? null,
|
||||
sessionKey: payload.sessionKey,
|
||||
timestampMs: assistantCompletionAt,
|
||||
role: "assistant",
|
||||
kind: "meta",
|
||||
entryId: terminalAssistantMetaEntryId(payload.runId ?? null),
|
||||
confirmed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (thinkingLine) {
|
||||
commands.push({
|
||||
kind: "appendOutput",
|
||||
line: thinkingLine,
|
||||
transcript: {
|
||||
source: "runtime-chat",
|
||||
runId: payload.runId ?? null,
|
||||
sessionKey: payload.sessionKey,
|
||||
timestampMs: assistantCompletionAt ?? nowMs,
|
||||
role: "assistant",
|
||||
kind: "thinking",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (toolLines.length > 0) {
|
||||
commands.push({
|
||||
kind: "appendToolLines",
|
||||
lines: toolLines,
|
||||
timestampMs: assistantCompletionAt ?? nowMs,
|
||||
});
|
||||
}
|
||||
if (!isToolRole && typeof finalAssistantText === "string") {
|
||||
commands.push({
|
||||
kind: "appendOutput",
|
||||
line: finalAssistantText,
|
||||
transcript: {
|
||||
source: "runtime-chat",
|
||||
runId: payload.runId ?? null,
|
||||
sessionKey: payload.sessionKey,
|
||||
timestampMs: assistantCompletionAt ?? nowMs,
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
entryId: terminalAssistantFinalEntryId(payload.runId ?? null),
|
||||
confirmed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (payload.runId) {
|
||||
commands.push({
|
||||
kind: "applyTerminalCommit",
|
||||
runId: payload.runId,
|
||||
seq: terminalSeq,
|
||||
});
|
||||
}
|
||||
commands.push({ kind: "applyPolicyIntents", intents: chatIntents });
|
||||
return { commands };
|
||||
}
|
||||
|
||||
if (payload.state === "aborted") {
|
||||
commands.push({
|
||||
kind: "appendAbortedIfNotSuppressed",
|
||||
timestampMs: nowMs,
|
||||
});
|
||||
commands.push({ kind: "applyPolicyIntents", intents: chatIntents });
|
||||
return { commands };
|
||||
}
|
||||
|
||||
if (payload.state === "error") {
|
||||
commands.push({
|
||||
kind: "appendOutput",
|
||||
line: payload.errorMessage ? `Error: ${payload.errorMessage}` : "Run error.",
|
||||
transcript: {
|
||||
source: "runtime-chat",
|
||||
runId: payload.runId ?? null,
|
||||
sessionKey: payload.sessionKey,
|
||||
timestampMs: nowMs,
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
},
|
||||
});
|
||||
commands.push({ kind: "applyPolicyIntents", intents: chatIntents });
|
||||
}
|
||||
|
||||
return { commands };
|
||||
};
|
||||
@@ -0,0 +1,662 @@
|
||||
import type { AgentState } from "./store";
|
||||
import {
|
||||
extractText,
|
||||
extractThinking,
|
||||
extractToolLines,
|
||||
formatMetaMarkdown,
|
||||
formatThinkingMarkdown,
|
||||
isHeartbeatPrompt,
|
||||
isMetaMarkdown,
|
||||
isToolMarkdown,
|
||||
isTraceMarkdown,
|
||||
isUiMetadataPrefix,
|
||||
stripUiMetadata,
|
||||
} from "@/lib/text/message-extract";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
|
||||
type LifecyclePhase = "start" | "end" | "error";
|
||||
|
||||
type LifecyclePatchInput = {
|
||||
phase: LifecyclePhase;
|
||||
incomingRunId: string;
|
||||
currentRunId: string | null;
|
||||
lastActivityAt: number;
|
||||
};
|
||||
|
||||
type LifecycleTransitionStart = {
|
||||
kind: "start";
|
||||
patch: Partial<AgentState>;
|
||||
clearRunTracking: false;
|
||||
};
|
||||
|
||||
type LifecycleTransitionTerminal = {
|
||||
kind: "terminal";
|
||||
patch: Partial<AgentState>;
|
||||
clearRunTracking: true;
|
||||
};
|
||||
|
||||
type LifecycleTransitionIgnore = {
|
||||
kind: "ignore";
|
||||
};
|
||||
|
||||
export type LifecycleTransition =
|
||||
| LifecycleTransitionStart
|
||||
| LifecycleTransitionTerminal
|
||||
| LifecycleTransitionIgnore;
|
||||
|
||||
type ShouldPublishAssistantStreamInput = {
|
||||
nextText: string;
|
||||
rawText: string;
|
||||
hasChatEvents: boolean;
|
||||
currentStreamText: string | null;
|
||||
};
|
||||
|
||||
type AssistantCompletionTimestampInput = {
|
||||
role: unknown;
|
||||
state: ChatEventPayload["state"];
|
||||
message: unknown;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
type DedupeRunLinesResult = {
|
||||
appended: string[];
|
||||
nextSeen: Set<string>;
|
||||
};
|
||||
|
||||
export type ChatEventPayload = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
state: "delta" | "final" | "aborted" | "error";
|
||||
seq?: number;
|
||||
stopReason?: string;
|
||||
message?: unknown;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export type AgentEventPayload = {
|
||||
runId: string;
|
||||
seq?: number;
|
||||
stream?: string;
|
||||
data?: Record<string, unknown>;
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export type SummarySnapshotAgent = {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
status?: AgentState["status"];
|
||||
};
|
||||
|
||||
export type SummarySessionStatusEntry = {
|
||||
key: string;
|
||||
updatedAt: number | null;
|
||||
};
|
||||
|
||||
export type SummaryStatusSnapshot = {
|
||||
sessions?: {
|
||||
recent?: SummarySessionStatusEntry[];
|
||||
byAgent?: Array<{ agentId: string; recent: SummarySessionStatusEntry[] }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type SummaryPreviewItem = {
|
||||
role: "user" | "assistant" | "tool" | "system" | "other";
|
||||
text: string;
|
||||
timestamp?: number | string;
|
||||
};
|
||||
|
||||
export type SummaryPreviewEntry = {
|
||||
key: string;
|
||||
status: "ok" | "empty" | "missing" | "error";
|
||||
items: SummaryPreviewItem[];
|
||||
};
|
||||
|
||||
export type SummaryPreviewSnapshot = {
|
||||
ts: number;
|
||||
previews: SummaryPreviewEntry[];
|
||||
};
|
||||
|
||||
export type SummarySnapshotPatch = {
|
||||
agentId: string;
|
||||
patch: Partial<AgentState>;
|
||||
};
|
||||
|
||||
export type ChatHistoryMessage = Record<string, unknown>;
|
||||
|
||||
export type HistoryLinesResult = {
|
||||
lines: string[];
|
||||
lastAssistant: string | null;
|
||||
lastAssistantAt: number | null;
|
||||
lastRole: string | null;
|
||||
lastUser: string | null;
|
||||
lastUserAt: number | null;
|
||||
};
|
||||
|
||||
export type HistorySyncPatchInput = {
|
||||
messages: ChatHistoryMessage[];
|
||||
currentLines: string[];
|
||||
loadedAt: number;
|
||||
status: AgentState["status"];
|
||||
runId: string | null;
|
||||
};
|
||||
|
||||
export type GatewayEventKind =
|
||||
| "summary-refresh"
|
||||
| "runtime-chat"
|
||||
| "runtime-agent"
|
||||
| "ignore";
|
||||
|
||||
const REASONING_STREAM_NAME_HINTS = ["reason", "think", "analysis", "trace"];
|
||||
|
||||
export const classifyGatewayEventKind = (event: string): GatewayEventKind => {
|
||||
if (event === "presence" || event === "heartbeat") return "summary-refresh";
|
||||
if (event === "chat") return "runtime-chat";
|
||||
if (event === "agent") return "runtime-agent";
|
||||
return "ignore";
|
||||
};
|
||||
|
||||
export const isReasoningRuntimeAgentStream = (stream: string): boolean => {
|
||||
const normalized = stream.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (normalized === "assistant" || normalized === "tool" || normalized === "lifecycle") {
|
||||
return false;
|
||||
}
|
||||
return REASONING_STREAM_NAME_HINTS.some((hint) => normalized.includes(hint));
|
||||
};
|
||||
|
||||
export const mergeRuntimeStream = (current: string, incoming: string): string => {
|
||||
if (!incoming) return current;
|
||||
if (!current) return incoming;
|
||||
if (incoming.startsWith(current)) return incoming;
|
||||
if (current.startsWith(incoming)) return current;
|
||||
if (current.endsWith(incoming)) return current;
|
||||
if (incoming.endsWith(current)) return incoming;
|
||||
return `${current}${incoming}`;
|
||||
};
|
||||
|
||||
export const dedupeRunLines = (seen: Set<string>, lines: string[]): DedupeRunLinesResult => {
|
||||
const nextSeen = new Set(seen);
|
||||
const appended: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line || nextSeen.has(line)) continue;
|
||||
nextSeen.add(line);
|
||||
appended.push(line);
|
||||
}
|
||||
return { appended, nextSeen };
|
||||
};
|
||||
|
||||
const toTimestampMs = (value: unknown): number | null => {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractMessageTimestamp = (message: unknown): number | null => {
|
||||
if (!message || typeof message !== "object") return null;
|
||||
const record = message as Record<string, unknown>;
|
||||
return (
|
||||
toTimestampMs(record.timestamp) ?? toTimestampMs(record.createdAt) ?? toTimestampMs(record.at)
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveAssistantCompletionTimestamp = ({
|
||||
role,
|
||||
state,
|
||||
message,
|
||||
now = Date.now(),
|
||||
}: AssistantCompletionTimestampInput): number | null => {
|
||||
if (role !== "assistant" || state !== "final") return null;
|
||||
return extractMessageTimestamp(message) ?? now;
|
||||
};
|
||||
|
||||
export const buildHistoryLines = (messages: ChatHistoryMessage[]): HistoryLinesResult => {
|
||||
const lines: string[] = [];
|
||||
let lastAssistant: string | null = null;
|
||||
let lastAssistantAt: number | null = null;
|
||||
let lastRole: string | null = null;
|
||||
let lastUser: string | null = null;
|
||||
let lastUserAt: number | null = null;
|
||||
const resolveAssistantTerminalLine = (message: ChatHistoryMessage): string | null => {
|
||||
const stopReason =
|
||||
typeof message.stopReason === "string" ? message.stopReason.trim().toLowerCase() : "";
|
||||
if (stopReason === "aborted") return "Run aborted.";
|
||||
if (stopReason === "error") {
|
||||
const errorMessage =
|
||||
typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
|
||||
return errorMessage ? `Error: ${errorMessage}` : "Run error.";
|
||||
}
|
||||
const fallbackError =
|
||||
typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
|
||||
return fallbackError ? `Error: ${fallbackError}` : null;
|
||||
};
|
||||
const isRestartSentinelMessage = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^(?:System:\s*\[[^\]]+\]\s*)?GatewayRestart:\s*\{/.test(trimmed);
|
||||
};
|
||||
for (const message of messages) {
|
||||
const role = typeof message.role === "string" ? message.role : "other";
|
||||
const extracted = extractText(message);
|
||||
const baseText = stripUiMetadata(extracted?.trim() ?? "");
|
||||
const text = role === "assistant" ? normalizeAssistantDisplayText(baseText) : baseText;
|
||||
const thinking =
|
||||
role === "assistant" ? formatThinkingMarkdown(extractThinking(message) ?? "") : "";
|
||||
const toolLines = extractToolLines(message);
|
||||
if (role === "system") {
|
||||
if (toolLines.length > 0) {
|
||||
lines.push(...toolLines);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (role === "user") {
|
||||
if (text && isHeartbeatPrompt(text)) continue;
|
||||
if (text && isRestartSentinelMessage(text)) continue;
|
||||
if (text) {
|
||||
const at = extractMessageTimestamp(message);
|
||||
if (typeof at === "number") {
|
||||
lines.push(formatMetaMarkdown({ role: "user", timestamp: at }));
|
||||
}
|
||||
lines.push(`> ${text}`);
|
||||
lastUser = text;
|
||||
if (typeof at === "number") {
|
||||
lastUserAt = at;
|
||||
}
|
||||
}
|
||||
lastRole = "user";
|
||||
} else if (role === "assistant") {
|
||||
const terminalLine =
|
||||
!text && !thinking && toolLines.length === 0 ? resolveAssistantTerminalLine(message) : null;
|
||||
if (!text && !thinking && toolLines.length === 0 && !terminalLine) {
|
||||
continue;
|
||||
}
|
||||
const at = extractMessageTimestamp(message);
|
||||
if (typeof at === "number") {
|
||||
lastAssistantAt = at;
|
||||
}
|
||||
if (typeof at === "number") {
|
||||
lines.push(formatMetaMarkdown({ role: "assistant", timestamp: at }));
|
||||
}
|
||||
if (thinking) {
|
||||
lines.push(thinking);
|
||||
}
|
||||
if (toolLines.length > 0) {
|
||||
lines.push(...toolLines);
|
||||
}
|
||||
if (text) {
|
||||
lines.push(text);
|
||||
lastAssistant = text;
|
||||
}
|
||||
if (terminalLine) {
|
||||
lines.push(terminalLine);
|
||||
lastAssistant = terminalLine;
|
||||
}
|
||||
lastRole = "assistant";
|
||||
} else if (toolLines.length > 0) {
|
||||
lines.push(...toolLines);
|
||||
} else if (text) {
|
||||
lines.push(text);
|
||||
}
|
||||
}
|
||||
return { lines, lastAssistant, lastAssistantAt, lastRole, lastUser, lastUserAt };
|
||||
};
|
||||
|
||||
const HISTORY_RUNNING_RECOVERY_WINDOW_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
export const resolveHistoryRunStatePatch = (params: {
|
||||
status: AgentState["status"];
|
||||
runId: string | null;
|
||||
lastRole: string | null;
|
||||
lastUserAt: number | null;
|
||||
loadedAt: number;
|
||||
}): Partial<AgentState> | null => {
|
||||
const activeRunId = params.runId?.trim() ?? "";
|
||||
if (activeRunId) return null;
|
||||
|
||||
if (params.status === "running" && params.lastRole === "assistant") {
|
||||
return {
|
||||
status: "idle",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.status === "running" || params.lastRole !== "user") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastUserAt = params.lastUserAt;
|
||||
if (typeof lastUserAt !== "number" || !Number.isFinite(lastUserAt)) {
|
||||
return null;
|
||||
}
|
||||
if (params.loadedAt < lastUserAt) {
|
||||
return null;
|
||||
}
|
||||
const ageMs = params.loadedAt - lastUserAt;
|
||||
if (ageMs > HISTORY_RUNNING_RECOVERY_WINDOW_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "running",
|
||||
runId: null,
|
||||
runStartedAt: lastUserAt,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const mergeHistoryWithPending = (
|
||||
historyLines: string[],
|
||||
currentLines: string[]
|
||||
): string[] => {
|
||||
const normalizeUserLine = (line: string): string | null => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith(">")) return null;
|
||||
const text = trimmed.replace(/^>\s?/, "");
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
return normalized || null;
|
||||
};
|
||||
|
||||
const isPlainAssistantLine = (line: string): boolean => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed.startsWith(">")) return false;
|
||||
if (isMetaMarkdown(trimmed)) return false;
|
||||
if (isTraceMarkdown(trimmed)) return false;
|
||||
if (isToolMarkdown(trimmed)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const countLines = (lines: string[]) => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const line of lines) {
|
||||
counts.set(line, (counts.get(line) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
if (currentLines.length === 0) return historyLines;
|
||||
if (historyLines.length === 0) return historyLines;
|
||||
const historyCounts = countLines(historyLines);
|
||||
const currentCounts = countLines(currentLines);
|
||||
const merged = [...historyLines];
|
||||
let cursor = 0;
|
||||
for (const line of currentLines) {
|
||||
let foundIndex = -1;
|
||||
for (let i = cursor; i < merged.length; i += 1) {
|
||||
if (merged[i] === line) {
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundIndex !== -1) {
|
||||
cursor = foundIndex + 1;
|
||||
continue;
|
||||
}
|
||||
const normalizedUserLine = normalizeUserLine(line);
|
||||
if (normalizedUserLine) {
|
||||
for (let i = cursor; i < merged.length; i += 1) {
|
||||
const normalizedMergedLine = normalizeUserLine(merged[i] ?? "");
|
||||
if (!normalizedMergedLine) continue;
|
||||
if (normalizedMergedLine !== normalizedUserLine) continue;
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
if (foundIndex !== -1) {
|
||||
cursor = foundIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
merged.splice(cursor, 0, line);
|
||||
cursor += 1;
|
||||
}
|
||||
const assistantLineCount = new Map<string, number>();
|
||||
const bounded: string[] = [];
|
||||
for (const line of merged) {
|
||||
if (!isPlainAssistantLine(line)) {
|
||||
bounded.push(line);
|
||||
continue;
|
||||
}
|
||||
const nextCount = (assistantLineCount.get(line) ?? 0) + 1;
|
||||
const historyCount = historyCounts.get(line) ?? 0;
|
||||
const currentCount = currentCounts.get(line) ?? 0;
|
||||
const hasOverlap = historyCount > 0 && currentCount > 0;
|
||||
if (hasOverlap && nextCount > historyCount) {
|
||||
continue;
|
||||
}
|
||||
assistantLineCount.set(line, nextCount);
|
||||
bounded.push(line);
|
||||
}
|
||||
return bounded;
|
||||
};
|
||||
|
||||
export const buildHistorySyncPatch = ({
|
||||
messages,
|
||||
currentLines,
|
||||
loadedAt,
|
||||
status,
|
||||
runId,
|
||||
}: HistorySyncPatchInput): Partial<AgentState> => {
|
||||
const { lines, lastAssistant, lastAssistantAt, lastRole, lastUser, lastUserAt } =
|
||||
buildHistoryLines(messages);
|
||||
const runStatePatch = resolveHistoryRunStatePatch({
|
||||
status,
|
||||
runId,
|
||||
lastRole,
|
||||
lastUserAt,
|
||||
loadedAt,
|
||||
});
|
||||
if (lines.length === 0) return { historyLoadedAt: loadedAt };
|
||||
const mergedLines = mergeHistoryWithPending(lines, currentLines);
|
||||
const isSame =
|
||||
mergedLines.length === currentLines.length &&
|
||||
mergedLines.every((line, index) => line === currentLines[index]);
|
||||
if (isSame) {
|
||||
const patch: Partial<AgentState> = {
|
||||
historyLoadedAt: loadedAt,
|
||||
...(runStatePatch ?? {}),
|
||||
};
|
||||
if (typeof lastAssistantAt === "number") {
|
||||
patch.lastAssistantMessageAt = lastAssistantAt;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
const patch: Partial<AgentState> = {
|
||||
outputLines: mergedLines,
|
||||
lastResult: lastAssistant ?? null,
|
||||
...(lastAssistant ? { latestPreview: lastAssistant } : {}),
|
||||
...(typeof lastAssistantAt === "number" ? { lastAssistantMessageAt: lastAssistantAt } : {}),
|
||||
...(lastUser ? { lastUserMessage: lastUser } : {}),
|
||||
historyLoadedAt: loadedAt,
|
||||
...(runStatePatch ?? {}),
|
||||
};
|
||||
return patch;
|
||||
};
|
||||
|
||||
export const buildSummarySnapshotPatches = ({
|
||||
agents,
|
||||
statusSummary,
|
||||
previewResult,
|
||||
}: {
|
||||
agents: SummarySnapshotAgent[];
|
||||
statusSummary: SummaryStatusSnapshot;
|
||||
previewResult: SummaryPreviewSnapshot;
|
||||
}): SummarySnapshotPatch[] => {
|
||||
const previewMap = new Map<string, SummaryPreviewEntry>();
|
||||
for (const entry of previewResult.previews ?? []) {
|
||||
previewMap.set(entry.key, entry);
|
||||
}
|
||||
const activityByKey = new Map<string, number>();
|
||||
const addActivity = (entries?: SummarySessionStatusEntry[]) => {
|
||||
if (!entries) return;
|
||||
for (const entry of entries) {
|
||||
if (!entry?.key || typeof entry.updatedAt !== "number") continue;
|
||||
activityByKey.set(entry.key, entry.updatedAt);
|
||||
}
|
||||
};
|
||||
addActivity(statusSummary.sessions?.recent);
|
||||
for (const group of statusSummary.sessions?.byAgent ?? []) {
|
||||
addActivity(group.recent);
|
||||
}
|
||||
const patches: SummarySnapshotPatch[] = [];
|
||||
for (const agent of agents) {
|
||||
const patch: Partial<AgentState> = {};
|
||||
const activity = activityByKey.get(agent.sessionKey);
|
||||
if (typeof activity === "number") {
|
||||
patch.lastActivityAt = activity;
|
||||
}
|
||||
const preview = previewMap.get(agent.sessionKey);
|
||||
if (preview?.items?.length) {
|
||||
const latestItem = preview.items[preview.items.length - 1];
|
||||
if (latestItem?.role === "assistant" && agent.status !== "running") {
|
||||
const previewTs = toTimestampMs(latestItem.timestamp);
|
||||
if (typeof previewTs === "number") {
|
||||
patch.lastAssistantMessageAt = previewTs;
|
||||
} else if (typeof activity === "number") {
|
||||
patch.lastAssistantMessageAt = activity;
|
||||
}
|
||||
}
|
||||
const lastAssistant = [...preview.items]
|
||||
.reverse()
|
||||
.find((item) => item.role === "assistant");
|
||||
const lastUser = [...preview.items].reverse().find((item) => item.role === "user");
|
||||
if (lastAssistant?.text) {
|
||||
patch.latestPreview = stripUiMetadata(lastAssistant.text);
|
||||
}
|
||||
if (lastUser?.text) {
|
||||
patch.lastUserMessage = stripUiMetadata(lastUser.text);
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
patches.push({ agentId: agent.agentId, patch });
|
||||
}
|
||||
}
|
||||
return patches;
|
||||
};
|
||||
|
||||
export const resolveLifecyclePatch = (input: LifecyclePatchInput): LifecycleTransition => {
|
||||
const { phase, incomingRunId, currentRunId, lastActivityAt } = input;
|
||||
if (phase === "start") {
|
||||
return {
|
||||
kind: "start",
|
||||
clearRunTracking: false,
|
||||
patch: {
|
||||
status: "running",
|
||||
runId: incomingRunId,
|
||||
runStartedAt: lastActivityAt,
|
||||
sessionCreated: true,
|
||||
lastActivityAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (currentRunId && currentRunId !== incomingRunId) {
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
if (phase === "error") {
|
||||
return {
|
||||
kind: "terminal",
|
||||
clearRunTracking: true,
|
||||
patch: {
|
||||
status: "error",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
lastActivityAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "terminal",
|
||||
clearRunTracking: true,
|
||||
patch: {
|
||||
status: "idle",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
lastActivityAt,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldPublishAssistantStream = ({
|
||||
nextText,
|
||||
rawText,
|
||||
hasChatEvents,
|
||||
currentStreamText,
|
||||
}: ShouldPublishAssistantStreamInput): boolean => {
|
||||
const next = nextText.trim();
|
||||
if (!next) return false;
|
||||
if (!hasChatEvents) return true;
|
||||
if (rawText.trim()) return true;
|
||||
const current = currentStreamText?.trim() ?? "";
|
||||
if (!current) return true;
|
||||
if (next.length <= current.length) return false;
|
||||
return next.startsWith(current);
|
||||
};
|
||||
|
||||
export const getChatSummaryPatch = (
|
||||
payload: ChatEventPayload,
|
||||
now: number = Date.now()
|
||||
): Partial<AgentState> | null => {
|
||||
const message = payload.message;
|
||||
const role =
|
||||
message && typeof message === "object"
|
||||
? (message as Record<string, unknown>).role
|
||||
: null;
|
||||
const rawText = extractText(message);
|
||||
if (typeof rawText === "string" && isUiMetadataPrefix(rawText.trim())) {
|
||||
return { lastActivityAt: now };
|
||||
}
|
||||
const cleaned = typeof rawText === "string" ? stripUiMetadata(rawText) : null;
|
||||
const patch: Partial<AgentState> = { lastActivityAt: now };
|
||||
if (role === "user") {
|
||||
if (cleaned) {
|
||||
patch.lastUserMessage = cleaned;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
if (role === "assistant") {
|
||||
if (cleaned) {
|
||||
patch.latestPreview = cleaned;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
if (payload.state === "error" && payload.errorMessage) {
|
||||
patch.latestPreview = payload.errorMessage;
|
||||
}
|
||||
return patch;
|
||||
};
|
||||
|
||||
export const getAgentSummaryPatch = (
|
||||
payload: AgentEventPayload,
|
||||
now: number = Date.now()
|
||||
): Partial<AgentState> | null => {
|
||||
if (payload.stream !== "lifecycle") return null;
|
||||
const phase = typeof payload.data?.phase === "string" ? payload.data.phase : "";
|
||||
if (!phase) return null;
|
||||
const patch: Partial<AgentState> = { lastActivityAt: now };
|
||||
if (phase === "start") {
|
||||
patch.status = "running";
|
||||
return patch;
|
||||
}
|
||||
if (phase === "end") {
|
||||
patch.status = "idle";
|
||||
return patch;
|
||||
}
|
||||
if (phase === "error") {
|
||||
patch.status = "error";
|
||||
return patch;
|
||||
}
|
||||
return patch;
|
||||
};
|
||||
@@ -0,0 +1,859 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { TranscriptAppendMeta } from "@/features/agents/state/transcript";
|
||||
import {
|
||||
dedupeRunLines,
|
||||
type AgentEventPayload,
|
||||
type ChatEventPayload,
|
||||
} from "@/features/agents/state/runtimeEventBridge";
|
||||
import type { RuntimePolicyIntent } from "@/features/agents/state/runtimeEventPolicy";
|
||||
import type { RuntimeChatWorkflowCommand } from "@/features/agents/state/runtimeChatEventWorkflow";
|
||||
import type { RuntimeAgentWorkflowCommand } from "@/features/agents/state/runtimeAgentEventWorkflow";
|
||||
import {
|
||||
applyTerminalCommit,
|
||||
clearRunTerminalState,
|
||||
createRuntimeTerminalState,
|
||||
deriveLifecycleTerminalDecision,
|
||||
markClosedRun,
|
||||
pruneClosedRuns,
|
||||
type RuntimeTerminalCommand,
|
||||
type RuntimeTerminalState,
|
||||
} from "@/features/agents/state/runtimeTerminalWorkflow";
|
||||
import { formatMetaMarkdown } from "@/lib/text/message-extract";
|
||||
|
||||
// The coordinator is the bridge between pure runtime workflow commands and real store effects.
|
||||
// It keeps the small amount of cross-stream bookkeeping needed to merge chat, agent, terminal,
|
||||
// and history-recovery behavior without pushing those concerns back into the page component.
|
||||
export type RuntimeEventCoordinatorState = {
|
||||
runtimeTerminalState: RuntimeTerminalState;
|
||||
// Tracks runs that already emitted chat traffic so agent lifecycle fallbacks know when
|
||||
// canonical history recovery is still needed.
|
||||
chatRunSeen: Set<string>;
|
||||
assistantStreamByRun: Map<string, string>;
|
||||
thinkingStreamByRun: Map<string, string>;
|
||||
thinkingStartedAtByRun: Map<string, number>;
|
||||
toolLinesSeenByRun: Map<string, Set<string>>;
|
||||
// Prevents duplicate history refresh requests for the same run when agent events arrive
|
||||
// before the canonical chat trace is visible.
|
||||
historyRefreshRequestedByRun: Set<string>;
|
||||
// Guards one-time debug/meta output per session instead of repeating it on every delta.
|
||||
thinkingDebugBySession: Set<string>;
|
||||
lastActivityMarkByAgent: Map<string, number>;
|
||||
};
|
||||
|
||||
export type RuntimeCoordinatorDispatchAction =
|
||||
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
| {
|
||||
type: "appendOutput";
|
||||
agentId: string;
|
||||
line: string;
|
||||
transcript?: TranscriptAppendMeta;
|
||||
}
|
||||
| { type: "markActivity"; agentId: string; at?: number };
|
||||
|
||||
export type RuntimeCoordinatorEffectCommand =
|
||||
| { kind: "dispatch"; action: RuntimeCoordinatorDispatchAction }
|
||||
| { kind: "queueLivePatch"; agentId: string; patch: Partial<AgentState> }
|
||||
| { kind: "clearPendingLivePatch"; agentId: string }
|
||||
| {
|
||||
kind: "requestHistoryRefresh";
|
||||
agentId: string;
|
||||
reason: "chat-final-no-trace" | "run-start-no-chat";
|
||||
sessionKey?: string;
|
||||
deferMs: number;
|
||||
}
|
||||
| {
|
||||
kind: "scheduleSummaryRefresh";
|
||||
delayMs: number;
|
||||
includeHeartbeatRefresh: boolean;
|
||||
}
|
||||
| { kind: "cancelLifecycleFallback"; runId: string }
|
||||
| {
|
||||
kind: "scheduleLifecycleFallback";
|
||||
runId: string;
|
||||
delayMs: number;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
finalText: string;
|
||||
transitionPatch: Partial<AgentState>;
|
||||
}
|
||||
| {
|
||||
kind: "appendAbortedIfNotSuppressed";
|
||||
agentId: string;
|
||||
runId: string | null;
|
||||
sessionKey: string;
|
||||
stopReason: string | null;
|
||||
timestampMs: number;
|
||||
}
|
||||
| { kind: "logMetric"; metric: string; meta: Record<string, unknown> }
|
||||
| { kind: "logWarn"; message: string; meta?: unknown }
|
||||
| {
|
||||
kind: "updateSpecialLatest";
|
||||
agentId: string;
|
||||
message: string;
|
||||
agentSnapshot?: AgentState;
|
||||
};
|
||||
|
||||
type ReduceResult = {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
effects: RuntimeCoordinatorEffectCommand[];
|
||||
};
|
||||
|
||||
type ReduceOptions = {
|
||||
closedRunTtlMs?: number;
|
||||
};
|
||||
|
||||
const CLOSED_RUN_TTL_MS = 30_000;
|
||||
const MARK_ACTIVITY_THROTTLE_MS = 300;
|
||||
|
||||
const toRunId = (runId?: string | null): string => runId?.trim() ?? "";
|
||||
|
||||
const terminalAssistantMetaEntryId = (runId?: string | null) => {
|
||||
const key = runId?.trim() ?? "";
|
||||
return key ? `run:${key}:assistant:meta` : undefined;
|
||||
};
|
||||
|
||||
const terminalAssistantFinalEntryId = (runId?: string | null) => {
|
||||
const key = runId?.trim() ?? "";
|
||||
return key ? `run:${key}:assistant:final` : undefined;
|
||||
};
|
||||
|
||||
const cloneState = (state: RuntimeEventCoordinatorState): RuntimeEventCoordinatorState => ({
|
||||
runtimeTerminalState: state.runtimeTerminalState,
|
||||
chatRunSeen: new Set(state.chatRunSeen),
|
||||
assistantStreamByRun: new Map(state.assistantStreamByRun),
|
||||
thinkingStreamByRun: new Map(state.thinkingStreamByRun),
|
||||
thinkingStartedAtByRun: new Map(state.thinkingStartedAtByRun),
|
||||
toolLinesSeenByRun: new Map(state.toolLinesSeenByRun),
|
||||
historyRefreshRequestedByRun: new Set(state.historyRefreshRequestedByRun),
|
||||
thinkingDebugBySession: new Set(state.thinkingDebugBySession),
|
||||
lastActivityMarkByAgent: new Map(state.lastActivityMarkByAgent),
|
||||
});
|
||||
|
||||
const clearRunTrackingState = (
|
||||
state: RuntimeEventCoordinatorState,
|
||||
runId?: string | null
|
||||
): ReduceResult => {
|
||||
const key = toRunId(runId);
|
||||
if (!key) return { state, effects: [] };
|
||||
const nextState = cloneState(state);
|
||||
nextState.chatRunSeen.delete(key);
|
||||
nextState.assistantStreamByRun.delete(key);
|
||||
nextState.thinkingStreamByRun.delete(key);
|
||||
nextState.thinkingStartedAtByRun.delete(key);
|
||||
nextState.toolLinesSeenByRun.delete(key);
|
||||
nextState.historyRefreshRequestedByRun.delete(key);
|
||||
return {
|
||||
state: nextState,
|
||||
effects: [{ kind: "cancelLifecycleFallback", runId: key }],
|
||||
};
|
||||
};
|
||||
|
||||
const applyRuntimeTerminalCommands = (params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
commands: RuntimeTerminalCommand[];
|
||||
nowMs: number;
|
||||
closedRunTtlMs: number;
|
||||
onScheduleLifecycleFallback?: (
|
||||
command: Extract<RuntimeTerminalCommand, { kind: "scheduleLifecycleFallback" }>
|
||||
) => RuntimeCoordinatorEffectCommand | null;
|
||||
}): ReduceResult => {
|
||||
let nextState = params.state;
|
||||
const effects: RuntimeCoordinatorEffectCommand[] = [];
|
||||
|
||||
for (const command of params.commands) {
|
||||
if (command.kind === "cancelLifecycleFallback") {
|
||||
effects.push({ kind: "cancelLifecycleFallback", runId: command.runId });
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "clearRunTerminalState") {
|
||||
effects.push({ kind: "cancelLifecycleFallback", runId: command.runId });
|
||||
nextState = {
|
||||
...nextState,
|
||||
runtimeTerminalState: clearRunTerminalState(nextState.runtimeTerminalState, {
|
||||
runId: command.runId,
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "markRunClosed") {
|
||||
nextState = {
|
||||
...nextState,
|
||||
runtimeTerminalState: markClosedRun(nextState.runtimeTerminalState, {
|
||||
runId: command.runId,
|
||||
now: params.nowMs,
|
||||
ttlMs: params.closedRunTtlMs,
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "clearRunTracking") {
|
||||
const cleared = clearRunTrackingState(nextState, command.runId);
|
||||
nextState = cleared.state;
|
||||
effects.push(...cleared.effects);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "scheduleLifecycleFallback") {
|
||||
const scheduled = params.onScheduleLifecycleFallback?.(command);
|
||||
if (scheduled) {
|
||||
effects.push(scheduled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { state: nextState, effects };
|
||||
};
|
||||
|
||||
const appendToolLinesEffects = (params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
agentId: string;
|
||||
runId: string | null;
|
||||
sessionKey: string | undefined;
|
||||
source: "runtime-chat" | "runtime-agent";
|
||||
timestampMs: number;
|
||||
lines: string[];
|
||||
}): ReduceResult => {
|
||||
const { agentId, runId, sessionKey, source, timestampMs, lines } = params;
|
||||
if (lines.length === 0) {
|
||||
return { state: params.state, effects: [] };
|
||||
}
|
||||
|
||||
if (!runId) {
|
||||
const effects: RuntimeCoordinatorEffectCommand[] = lines.map((line) => ({
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line,
|
||||
transcript: {
|
||||
source,
|
||||
runId: null,
|
||||
sessionKey,
|
||||
timestampMs,
|
||||
kind: "tool",
|
||||
role: "tool",
|
||||
},
|
||||
},
|
||||
}));
|
||||
return { state: params.state, effects };
|
||||
}
|
||||
|
||||
const current = params.state.toolLinesSeenByRun.get(runId) ?? new Set<string>();
|
||||
const { appended, nextSeen } = dedupeRunLines(current, lines);
|
||||
if (appended.length === 0) {
|
||||
return { state: params.state, effects: [] };
|
||||
}
|
||||
|
||||
const nextToolLinesSeenByRun = new Map(params.state.toolLinesSeenByRun);
|
||||
nextToolLinesSeenByRun.set(runId, nextSeen);
|
||||
const nextState = {
|
||||
...params.state,
|
||||
toolLinesSeenByRun: nextToolLinesSeenByRun,
|
||||
};
|
||||
const effects: RuntimeCoordinatorEffectCommand[] = appended.map((line) => ({
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line,
|
||||
transcript: {
|
||||
source,
|
||||
runId,
|
||||
sessionKey,
|
||||
timestampMs,
|
||||
kind: "tool",
|
||||
role: "tool",
|
||||
},
|
||||
},
|
||||
}));
|
||||
return { state: nextState, effects };
|
||||
};
|
||||
|
||||
const reduceMarkActivity = (params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
agentId: string;
|
||||
at: number;
|
||||
}): ReduceResult => {
|
||||
const lastAt = params.state.lastActivityMarkByAgent.get(params.agentId) ?? 0;
|
||||
if (params.at - lastAt < MARK_ACTIVITY_THROTTLE_MS) {
|
||||
return { state: params.state, effects: [] };
|
||||
}
|
||||
const nextLastActivity = new Map(params.state.lastActivityMarkByAgent);
|
||||
nextLastActivity.set(params.agentId, params.at);
|
||||
return {
|
||||
state: {
|
||||
...params.state,
|
||||
lastActivityMarkByAgent: nextLastActivity,
|
||||
},
|
||||
effects: [
|
||||
{
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "markActivity",
|
||||
agentId: params.agentId,
|
||||
at: params.at,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export function reduceMarkActivityThrottled(params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
agentId: string;
|
||||
at: number;
|
||||
}): ReduceResult {
|
||||
return reduceMarkActivity(params);
|
||||
}
|
||||
|
||||
export function createRuntimeEventCoordinatorState(): RuntimeEventCoordinatorState {
|
||||
return {
|
||||
runtimeTerminalState: createRuntimeTerminalState(),
|
||||
chatRunSeen: new Set<string>(),
|
||||
assistantStreamByRun: new Map<string, string>(),
|
||||
thinkingStreamByRun: new Map<string, string>(),
|
||||
thinkingStartedAtByRun: new Map<string, number>(),
|
||||
toolLinesSeenByRun: new Map<string, Set<string>>(),
|
||||
historyRefreshRequestedByRun: new Set<string>(),
|
||||
thinkingDebugBySession: new Set<string>(),
|
||||
lastActivityMarkByAgent: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
|
||||
export function markChatRunSeen(
|
||||
state: RuntimeEventCoordinatorState,
|
||||
runId?: string | null
|
||||
): RuntimeEventCoordinatorState {
|
||||
const key = toRunId(runId);
|
||||
if (!key) return state;
|
||||
if (state.chatRunSeen.has(key)) return state;
|
||||
const nextChatRunSeen = new Set(state.chatRunSeen);
|
||||
nextChatRunSeen.add(key);
|
||||
return {
|
||||
...state,
|
||||
chatRunSeen: nextChatRunSeen,
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceClearRunTracking(params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
runId?: string | null;
|
||||
}): ReduceResult {
|
||||
return clearRunTrackingState(params.state, params.runId);
|
||||
}
|
||||
|
||||
export function pruneRuntimeEventCoordinatorState(params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
at: number;
|
||||
}): ReduceResult {
|
||||
const pruned = pruneClosedRuns(params.state.runtimeTerminalState, { at: params.at });
|
||||
if (pruned.expiredRunIds.length === 0) {
|
||||
return { state: params.state, effects: [] };
|
||||
}
|
||||
const effects: RuntimeCoordinatorEffectCommand[] = pruned.expiredRunIds.map((runId) => ({
|
||||
kind: "cancelLifecycleFallback",
|
||||
runId,
|
||||
}));
|
||||
return {
|
||||
state: {
|
||||
...params.state,
|
||||
runtimeTerminalState: pruned.state,
|
||||
},
|
||||
effects,
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceRuntimePolicyIntents(params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
intents: RuntimePolicyIntent[];
|
||||
nowMs: number;
|
||||
agentForLatestUpdate?: AgentState;
|
||||
options?: ReduceOptions;
|
||||
}): ReduceResult {
|
||||
// Policy intents are the common currency shared by summary-refresh, chat, and agent
|
||||
// workflows. Converting them here keeps the rest of the runtime stack side-effect free.
|
||||
let nextState = params.state;
|
||||
const effects: RuntimeCoordinatorEffectCommand[] = [];
|
||||
const closedRunTtlMs = params.options?.closedRunTtlMs ?? CLOSED_RUN_TTL_MS;
|
||||
|
||||
for (const intent of params.intents) {
|
||||
if (intent.kind === "ignore") {
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === "clearRunTracking") {
|
||||
const cleared = clearRunTrackingState(nextState, intent.runId);
|
||||
nextState = cleared.state;
|
||||
effects.push(...cleared.effects);
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === "markRunClosed") {
|
||||
nextState = {
|
||||
...nextState,
|
||||
runtimeTerminalState: markClosedRun(nextState.runtimeTerminalState, {
|
||||
runId: intent.runId,
|
||||
now: params.nowMs,
|
||||
ttlMs: closedRunTtlMs,
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === "markThinkingStarted") {
|
||||
if (!nextState.thinkingStartedAtByRun.has(intent.runId)) {
|
||||
const nextThinkingStartedAtByRun = new Map(nextState.thinkingStartedAtByRun);
|
||||
nextThinkingStartedAtByRun.set(intent.runId, intent.at);
|
||||
nextState = {
|
||||
...nextState,
|
||||
thinkingStartedAtByRun: nextThinkingStartedAtByRun,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === "clearPendingLivePatch") {
|
||||
effects.push({ kind: "clearPendingLivePatch", agentId: intent.agentId });
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === "queueLivePatch") {
|
||||
effects.push({
|
||||
kind: "queueLivePatch",
|
||||
agentId: intent.agentId,
|
||||
patch: intent.patch,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === "dispatchUpdateAgent") {
|
||||
effects.push({
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "updateAgent",
|
||||
agentId: intent.agentId,
|
||||
patch: intent.patch,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === "requestHistoryRefresh") {
|
||||
effects.push({
|
||||
kind: "requestHistoryRefresh",
|
||||
agentId: intent.agentId,
|
||||
reason: intent.reason,
|
||||
sessionKey: undefined,
|
||||
deferMs: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === "queueLatestUpdate") {
|
||||
const agentSnapshot =
|
||||
params.agentForLatestUpdate?.agentId === intent.agentId
|
||||
? params.agentForLatestUpdate
|
||||
: undefined;
|
||||
effects.push({
|
||||
kind: "updateSpecialLatest",
|
||||
agentId: intent.agentId,
|
||||
message: intent.message,
|
||||
agentSnapshot,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === "scheduleSummaryRefresh") {
|
||||
effects.push({
|
||||
kind: "scheduleSummaryRefresh",
|
||||
delayMs: intent.delayMs,
|
||||
includeHeartbeatRefresh: intent.includeHeartbeatRefresh,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { state: nextState, effects };
|
||||
}
|
||||
|
||||
export function reduceRuntimeChatWorkflowCommands(params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
payload: ChatEventPayload;
|
||||
agentId: string;
|
||||
agent: AgentState | undefined;
|
||||
commands: RuntimeChatWorkflowCommand[];
|
||||
nowMs: number;
|
||||
options?: ReduceOptions;
|
||||
}): ReduceResult {
|
||||
let nextState = params.state;
|
||||
const effects: RuntimeCoordinatorEffectCommand[] = [];
|
||||
const closedRunTtlMs = params.options?.closedRunTtlMs ?? CLOSED_RUN_TTL_MS;
|
||||
|
||||
for (const command of params.commands) {
|
||||
if (command.kind === "applyChatTerminalDecision") {
|
||||
nextState = {
|
||||
...nextState,
|
||||
runtimeTerminalState: command.decision.state,
|
||||
};
|
||||
const terminalReduced = applyRuntimeTerminalCommands({
|
||||
state: nextState,
|
||||
commands: command.decision.commands,
|
||||
nowMs: params.nowMs,
|
||||
closedRunTtlMs,
|
||||
});
|
||||
nextState = terminalReduced.state;
|
||||
effects.push(...terminalReduced.effects);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "logMetric") {
|
||||
effects.push({ kind: "logMetric", metric: command.metric, meta: command.meta });
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "markThinkingDebugSession") {
|
||||
if (!nextState.thinkingDebugBySession.has(command.sessionKey)) {
|
||||
const nextThinkingDebugBySession = new Set(nextState.thinkingDebugBySession);
|
||||
nextThinkingDebugBySession.add(command.sessionKey);
|
||||
nextState = {
|
||||
...nextState,
|
||||
thinkingDebugBySession: nextThinkingDebugBySession,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "logWarn") {
|
||||
effects.push({ kind: "logWarn", message: command.message, meta: command.meta });
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "appendOutput") {
|
||||
effects.push({
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "appendOutput",
|
||||
agentId: params.agentId,
|
||||
line: command.line,
|
||||
transcript: command.transcript,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "appendToolLines") {
|
||||
const toolLinesReduced = appendToolLinesEffects({
|
||||
state: nextState,
|
||||
agentId: params.agentId,
|
||||
runId: params.payload.runId ?? null,
|
||||
sessionKey: params.payload.sessionKey,
|
||||
source: "runtime-chat",
|
||||
timestampMs: command.timestampMs,
|
||||
lines: command.lines,
|
||||
});
|
||||
nextState = toolLinesReduced.state;
|
||||
effects.push(...toolLinesReduced.effects);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "applyTerminalCommit") {
|
||||
nextState = {
|
||||
...nextState,
|
||||
runtimeTerminalState: applyTerminalCommit(nextState.runtimeTerminalState, {
|
||||
runId: command.runId,
|
||||
source: "chat-final",
|
||||
seq: command.seq,
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "appendAbortedIfNotSuppressed") {
|
||||
effects.push({
|
||||
kind: "appendAbortedIfNotSuppressed",
|
||||
agentId: params.agentId,
|
||||
runId: params.payload.runId ?? null,
|
||||
sessionKey: params.payload.sessionKey,
|
||||
stopReason: params.payload.stopReason?.trim() ?? null,
|
||||
timestampMs: command.timestampMs,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "applyPolicyIntents") {
|
||||
const policyReduced = reduceRuntimePolicyIntents({
|
||||
state: nextState,
|
||||
intents: command.intents,
|
||||
nowMs: params.nowMs,
|
||||
agentForLatestUpdate: params.agent,
|
||||
options: { closedRunTtlMs },
|
||||
});
|
||||
nextState = policyReduced.state;
|
||||
effects.push(...policyReduced.effects);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { state: nextState, effects };
|
||||
}
|
||||
|
||||
export function reduceRuntimeAgentWorkflowCommands(params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
payload: AgentEventPayload;
|
||||
agentId: string;
|
||||
agent: AgentState;
|
||||
commands: RuntimeAgentWorkflowCommand[];
|
||||
nowMs: number;
|
||||
options?: ReduceOptions;
|
||||
}): ReduceResult {
|
||||
let nextState = params.state;
|
||||
const effects: RuntimeCoordinatorEffectCommand[] = [];
|
||||
const closedRunTtlMs = params.options?.closedRunTtlMs ?? CLOSED_RUN_TTL_MS;
|
||||
|
||||
for (const command of params.commands) {
|
||||
if (command.kind === "applyPolicyIntents") {
|
||||
const policyReduced = reduceRuntimePolicyIntents({
|
||||
state: nextState,
|
||||
intents: command.intents,
|
||||
nowMs: params.nowMs,
|
||||
options: { closedRunTtlMs },
|
||||
});
|
||||
nextState = policyReduced.state;
|
||||
effects.push(...policyReduced.effects);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "logMetric") {
|
||||
effects.push({ kind: "logMetric", metric: command.metric, meta: command.meta });
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "markActivity") {
|
||||
const activityReduced = reduceMarkActivity({
|
||||
state: nextState,
|
||||
agentId: params.agentId,
|
||||
at: command.at,
|
||||
});
|
||||
nextState = activityReduced.state;
|
||||
effects.push(...activityReduced.effects);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "setThinkingStreamRaw") {
|
||||
const nextThinkingStreamByRun = new Map(nextState.thinkingStreamByRun);
|
||||
nextThinkingStreamByRun.set(command.runId, command.raw);
|
||||
nextState = {
|
||||
...nextState,
|
||||
thinkingStreamByRun: nextThinkingStreamByRun,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "setAssistantStreamRaw") {
|
||||
const nextAssistantStreamByRun = new Map(nextState.assistantStreamByRun);
|
||||
nextAssistantStreamByRun.set(command.runId, command.raw);
|
||||
nextState = {
|
||||
...nextState,
|
||||
assistantStreamByRun: nextAssistantStreamByRun,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "markThinkingStarted") {
|
||||
if (!nextState.thinkingStartedAtByRun.has(command.runId)) {
|
||||
const nextThinkingStartedAtByRun = new Map(nextState.thinkingStartedAtByRun);
|
||||
nextThinkingStartedAtByRun.set(command.runId, command.at);
|
||||
nextState = {
|
||||
...nextState,
|
||||
thinkingStartedAtByRun: nextThinkingStartedAtByRun,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "queueAgentPatch") {
|
||||
effects.push({
|
||||
kind: "queueLivePatch",
|
||||
agentId: params.agentId,
|
||||
patch: command.patch,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "appendToolLines") {
|
||||
const toolLinesReduced = appendToolLinesEffects({
|
||||
state: nextState,
|
||||
agentId: params.agentId,
|
||||
runId: params.payload.runId ?? null,
|
||||
sessionKey: params.payload.sessionKey ?? params.agent.sessionKey,
|
||||
source: "runtime-agent",
|
||||
timestampMs: command.timestampMs,
|
||||
lines: command.lines,
|
||||
});
|
||||
nextState = toolLinesReduced.state;
|
||||
effects.push(...toolLinesReduced.effects);
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "markHistoryRefreshRequested") {
|
||||
const nextHistoryRefreshRequestedByRun = new Set(nextState.historyRefreshRequestedByRun);
|
||||
nextHistoryRefreshRequestedByRun.add(command.runId);
|
||||
nextState = {
|
||||
...nextState,
|
||||
historyRefreshRequestedByRun: nextHistoryRefreshRequestedByRun,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "scheduleHistoryRefresh") {
|
||||
effects.push({
|
||||
kind: "requestHistoryRefresh",
|
||||
agentId: params.agentId,
|
||||
reason: command.reason,
|
||||
sessionKey: params.payload.sessionKey ?? params.agent.sessionKey,
|
||||
deferMs: command.delayMs,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "applyLifecycleDecision") {
|
||||
if (command.shouldClearPendingLivePatch) {
|
||||
effects.push({
|
||||
kind: "clearPendingLivePatch",
|
||||
agentId: params.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
runtimeTerminalState: command.decision.state,
|
||||
};
|
||||
|
||||
const terminalReduced = applyRuntimeTerminalCommands({
|
||||
state: nextState,
|
||||
commands: command.decision.commands,
|
||||
nowMs: params.nowMs,
|
||||
closedRunTtlMs,
|
||||
onScheduleLifecycleFallback: (scheduledCommand) => ({
|
||||
kind: "scheduleLifecycleFallback",
|
||||
runId: scheduledCommand.runId,
|
||||
delayMs: scheduledCommand.delayMs,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.payload.sessionKey ?? params.agent.sessionKey,
|
||||
finalText: scheduledCommand.finalText,
|
||||
transitionPatch: command.transitionPatch,
|
||||
}),
|
||||
});
|
||||
|
||||
nextState = terminalReduced.state;
|
||||
effects.push(...terminalReduced.effects);
|
||||
|
||||
if (!command.decision.deferTransitionPatch) {
|
||||
effects.push({
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "updateAgent",
|
||||
agentId: params.agentId,
|
||||
patch: command.transitionPatch,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { state: nextState, effects };
|
||||
}
|
||||
|
||||
export function reduceLifecycleFallbackFired(params: {
|
||||
state: RuntimeEventCoordinatorState;
|
||||
runId: string;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
finalText: string;
|
||||
transitionPatch: Partial<AgentState>;
|
||||
nowMs: number;
|
||||
options?: ReduceOptions;
|
||||
}): ReduceResult {
|
||||
const closedRunTtlMs = params.options?.closedRunTtlMs ?? CLOSED_RUN_TTL_MS;
|
||||
let nextState = params.state;
|
||||
const effects: RuntimeCoordinatorEffectCommand[] = [];
|
||||
const runId = toRunId(params.runId);
|
||||
if (!runId) return { state: nextState, effects };
|
||||
|
||||
const fallbackDecision = deriveLifecycleTerminalDecision({
|
||||
mode: "fallback-fired",
|
||||
state: nextState.runtimeTerminalState,
|
||||
runId,
|
||||
});
|
||||
nextState = {
|
||||
...nextState,
|
||||
runtimeTerminalState: fallbackDecision.state,
|
||||
};
|
||||
|
||||
if (!fallbackDecision.shouldCommitFallback) {
|
||||
return { state: nextState, effects };
|
||||
}
|
||||
|
||||
const assistantCompletionAt = params.nowMs;
|
||||
const startedAt = nextState.thinkingStartedAtByRun.get(runId);
|
||||
const thinkingDurationMs =
|
||||
typeof startedAt === "number"
|
||||
? Math.max(0, assistantCompletionAt - startedAt)
|
||||
: null;
|
||||
|
||||
effects.push({
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "appendOutput",
|
||||
agentId: params.agentId,
|
||||
line: formatMetaMarkdown({
|
||||
role: "assistant",
|
||||
timestamp: assistantCompletionAt,
|
||||
thinkingDurationMs,
|
||||
}),
|
||||
transcript: {
|
||||
source: "runtime-agent",
|
||||
runId,
|
||||
sessionKey: params.sessionKey,
|
||||
timestampMs: assistantCompletionAt,
|
||||
role: "assistant",
|
||||
kind: "meta",
|
||||
entryId: terminalAssistantMetaEntryId(runId),
|
||||
confirmed: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (params.finalText) {
|
||||
effects.push({
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "appendOutput",
|
||||
agentId: params.agentId,
|
||||
line: params.finalText,
|
||||
transcript: {
|
||||
source: "runtime-agent",
|
||||
runId,
|
||||
sessionKey: params.sessionKey,
|
||||
timestampMs: assistantCompletionAt,
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
entryId: terminalAssistantFinalEntryId(runId),
|
||||
confirmed: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
effects.push({
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "updateAgent",
|
||||
agentId: params.agentId,
|
||||
patch: {
|
||||
lastResult: params.finalText,
|
||||
lastAssistantMessageAt: assistantCompletionAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
runtimeTerminalState: applyTerminalCommit(nextState.runtimeTerminalState, {
|
||||
runId,
|
||||
source: "lifecycle-fallback",
|
||||
seq: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const terminalReduced = applyRuntimeTerminalCommands({
|
||||
state: nextState,
|
||||
commands: fallbackDecision.commands,
|
||||
nowMs: params.nowMs,
|
||||
closedRunTtlMs,
|
||||
});
|
||||
nextState = terminalReduced.state;
|
||||
effects.push(...terminalReduced.effects);
|
||||
|
||||
effects.push({
|
||||
kind: "dispatch",
|
||||
action: {
|
||||
type: "updateAgent",
|
||||
agentId: params.agentId,
|
||||
patch: params.transitionPatch,
|
||||
},
|
||||
});
|
||||
|
||||
return { state: nextState, effects };
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { ChatEventPayload } from "@/features/agents/state/runtimeEventBridge";
|
||||
|
||||
type ConnectionStatus = "disconnected" | "connecting" | "connected";
|
||||
|
||||
export type RuntimePolicyIntent =
|
||||
| { kind: "ignore"; reason: string }
|
||||
| { kind: "clearRunTracking"; runId: string }
|
||||
| { kind: "markRunClosed"; runId: string }
|
||||
| { kind: "markThinkingStarted"; runId: string; at: number }
|
||||
| { kind: "clearPendingLivePatch"; agentId: string }
|
||||
| { kind: "queueLivePatch"; agentId: string; patch: Partial<AgentState> }
|
||||
| { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
| { kind: "requestHistoryRefresh"; agentId: string; reason: "chat-final-no-trace" }
|
||||
| { kind: "queueLatestUpdate"; agentId: string; message: string }
|
||||
| { kind: "scheduleSummaryRefresh"; delayMs: number; includeHeartbeatRefresh: boolean };
|
||||
|
||||
export type RuntimeChatPolicyInput = {
|
||||
agentId: string;
|
||||
state: ChatEventPayload["state"];
|
||||
runId: string | null;
|
||||
role: unknown;
|
||||
activeRunId: string | null;
|
||||
agentStatus: AgentState["status"];
|
||||
now: number;
|
||||
agentRunStartedAt: number | null;
|
||||
nextThinking: string | null;
|
||||
nextText: string | null;
|
||||
hasThinkingStarted: boolean;
|
||||
isClosedRun: boolean;
|
||||
isStaleTerminal: boolean;
|
||||
shouldRequestHistoryRefresh: boolean;
|
||||
shouldUpdateLastResult: boolean;
|
||||
shouldSetRunIdle: boolean;
|
||||
shouldSetRunError: boolean;
|
||||
lastResultText: string | null;
|
||||
assistantCompletionAt: number | null;
|
||||
shouldQueueLatestUpdate: boolean;
|
||||
latestUpdateMessage: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeAgentPolicyInput = {
|
||||
runId: string;
|
||||
stream: string;
|
||||
phase: string;
|
||||
activeRunId: string | null;
|
||||
agentStatus: AgentState["status"];
|
||||
isClosedRun: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeSummaryPolicyInput = {
|
||||
event: string;
|
||||
status: ConnectionStatus;
|
||||
};
|
||||
|
||||
const isLifecycleStart = (stream: string, phase: string): boolean =>
|
||||
stream === "lifecycle" && phase === "start";
|
||||
|
||||
const toRunId = (runId: string | null | undefined): string => runId?.trim() ?? "";
|
||||
|
||||
export const decideRuntimeChatEvent = (
|
||||
input: RuntimeChatPolicyInput
|
||||
): RuntimePolicyIntent[] => {
|
||||
const runId = toRunId(input.runId);
|
||||
const activeRunId = toRunId(input.activeRunId);
|
||||
const role = input.role;
|
||||
|
||||
if (input.state === "delta") {
|
||||
if (runId && input.isClosedRun) {
|
||||
return [{ kind: "ignore", reason: "closed-run-delta" }];
|
||||
}
|
||||
if (runId && activeRunId && activeRunId !== runId) {
|
||||
return [{ kind: "clearRunTracking", runId }];
|
||||
}
|
||||
if (
|
||||
!activeRunId &&
|
||||
input.agentStatus !== "running" &&
|
||||
role !== "user" &&
|
||||
role !== "system"
|
||||
) {
|
||||
return runId
|
||||
? [{ kind: "clearRunTracking", runId }]
|
||||
: [{ kind: "ignore", reason: "inactive-agent-delta" }];
|
||||
}
|
||||
if (role === "user" || role === "system") {
|
||||
return [];
|
||||
}
|
||||
const patch: Partial<AgentState> = {};
|
||||
const intents: RuntimePolicyIntent[] = [];
|
||||
if (input.nextThinking) {
|
||||
if (runId && !input.hasThinkingStarted) {
|
||||
intents.push({ kind: "markThinkingStarted", runId, at: input.now });
|
||||
}
|
||||
patch.thinkingTrace = input.nextThinking;
|
||||
patch.status = "running";
|
||||
}
|
||||
if (typeof input.nextText === "string") {
|
||||
patch.streamText = input.nextText;
|
||||
patch.status = "running";
|
||||
}
|
||||
if (runId) {
|
||||
patch.runId = runId;
|
||||
}
|
||||
if (input.agentRunStartedAt === null) {
|
||||
patch.runStartedAt = input.now;
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
intents.push({
|
||||
kind: "queueLivePatch",
|
||||
agentId: input.agentId,
|
||||
patch,
|
||||
});
|
||||
}
|
||||
return intents;
|
||||
}
|
||||
|
||||
if (runId && activeRunId && activeRunId !== runId) {
|
||||
return [{ kind: "clearRunTracking", runId }];
|
||||
}
|
||||
if (runId && input.isStaleTerminal) {
|
||||
return [{ kind: "ignore", reason: "stale-terminal-event" }];
|
||||
}
|
||||
|
||||
const intents: RuntimePolicyIntent[] = [
|
||||
{ kind: "clearPendingLivePatch", agentId: input.agentId },
|
||||
];
|
||||
if (runId) {
|
||||
intents.push({ kind: "clearRunTracking", runId });
|
||||
intents.push({ kind: "markRunClosed", runId });
|
||||
}
|
||||
|
||||
if (input.state === "final") {
|
||||
if (input.shouldRequestHistoryRefresh) {
|
||||
intents.push({
|
||||
kind: "requestHistoryRefresh",
|
||||
agentId: input.agentId,
|
||||
reason: "chat-final-no-trace",
|
||||
});
|
||||
}
|
||||
if (input.shouldUpdateLastResult && input.lastResultText) {
|
||||
intents.push({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: input.agentId,
|
||||
patch: { lastResult: input.lastResultText },
|
||||
});
|
||||
}
|
||||
if (input.shouldQueueLatestUpdate && input.latestUpdateMessage) {
|
||||
intents.push({
|
||||
kind: "queueLatestUpdate",
|
||||
agentId: input.agentId,
|
||||
message: input.latestUpdateMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Partial<AgentState> = {
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
runStartedAt: null,
|
||||
};
|
||||
if (typeof input.assistantCompletionAt === "number") {
|
||||
patch.lastAssistantMessageAt = input.assistantCompletionAt;
|
||||
}
|
||||
if (input.shouldSetRunIdle) {
|
||||
patch.status = "idle";
|
||||
patch.runId = null;
|
||||
} else if (input.shouldSetRunError) {
|
||||
patch.status = "error";
|
||||
patch.runId = null;
|
||||
}
|
||||
|
||||
intents.push({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: input.agentId,
|
||||
patch,
|
||||
});
|
||||
|
||||
return intents;
|
||||
};
|
||||
|
||||
export const decideRuntimeAgentEvent = (
|
||||
input: RuntimeAgentPolicyInput
|
||||
): RuntimePolicyIntent[] => {
|
||||
if (!isLifecycleStart(input.stream, input.phase) && input.isClosedRun) {
|
||||
return [{ kind: "ignore", reason: "closed-run-event" }];
|
||||
}
|
||||
if (input.activeRunId && input.activeRunId !== input.runId) {
|
||||
if (!isLifecycleStart(input.stream, input.phase)) {
|
||||
return [{ kind: "clearRunTracking", runId: input.runId }];
|
||||
}
|
||||
}
|
||||
if (!input.activeRunId && input.agentStatus !== "running") {
|
||||
if (!isLifecycleStart(input.stream, input.phase)) {
|
||||
return [{ kind: "clearRunTracking", runId: input.runId }];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const decideSummaryRefreshEvent = (
|
||||
input: RuntimeSummaryPolicyInput
|
||||
): RuntimePolicyIntent[] => {
|
||||
if (input.status !== "connected") return [];
|
||||
if (input.event !== "presence" && input.event !== "heartbeat") return [];
|
||||
return [
|
||||
{
|
||||
kind: "scheduleSummaryRefresh",
|
||||
delayMs: 750,
|
||||
includeHeartbeatRefresh: input.event === "heartbeat",
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
export type RuntimeTerminalCommitSource = "chat-final" | "lifecycle-fallback";
|
||||
|
||||
export type RuntimeTerminalRunState = {
|
||||
chatFinalSeen: boolean;
|
||||
terminalCommitted: boolean;
|
||||
lastTerminalSeq: number | null;
|
||||
commitSource: RuntimeTerminalCommitSource | null;
|
||||
};
|
||||
|
||||
export type RuntimeTerminalState = {
|
||||
runStateByRun: ReadonlyMap<string, RuntimeTerminalRunState>;
|
||||
closedRunExpiresByRun: ReadonlyMap<string, number>;
|
||||
};
|
||||
|
||||
export type RuntimeTerminalCommand =
|
||||
| { kind: "scheduleLifecycleFallback"; runId: string; delayMs: number; finalText: string }
|
||||
| { kind: "cancelLifecycleFallback"; runId: string }
|
||||
| { kind: "clearRunTerminalState"; runId: string }
|
||||
| { kind: "markRunClosed"; runId: string }
|
||||
| { kind: "clearRunTracking"; runId: string };
|
||||
|
||||
export type ChatTerminalDecision = {
|
||||
state: RuntimeTerminalState;
|
||||
commands: RuntimeTerminalCommand[];
|
||||
isStaleTerminal: boolean;
|
||||
fallbackCommittedBeforeFinal: boolean;
|
||||
lastTerminalSeqBeforeFinal: number | null;
|
||||
commitSourceBeforeFinal: RuntimeTerminalCommitSource | null;
|
||||
};
|
||||
|
||||
type LifecycleTerminalEventDecisionInput = {
|
||||
mode: "event";
|
||||
state: RuntimeTerminalState;
|
||||
runId?: string | null;
|
||||
phase: string;
|
||||
hasPendingFallbackTimer: boolean;
|
||||
fallbackDelayMs: number;
|
||||
fallbackFinalText: string | null;
|
||||
transitionClearsRunTracking: boolean;
|
||||
};
|
||||
|
||||
type LifecycleTerminalFallbackFireDecisionInput = {
|
||||
mode: "fallback-fired";
|
||||
state: RuntimeTerminalState;
|
||||
runId?: string | null;
|
||||
};
|
||||
|
||||
export type LifecycleTerminalDecisionInput =
|
||||
| LifecycleTerminalEventDecisionInput
|
||||
| LifecycleTerminalFallbackFireDecisionInput;
|
||||
|
||||
export type LifecycleTerminalDecision = {
|
||||
state: RuntimeTerminalState;
|
||||
commands: RuntimeTerminalCommand[];
|
||||
shouldCommitFallback: boolean;
|
||||
deferTransitionPatch: boolean;
|
||||
};
|
||||
|
||||
const emptyRunState = (): RuntimeTerminalRunState => ({
|
||||
chatFinalSeen: false,
|
||||
terminalCommitted: false,
|
||||
lastTerminalSeq: null,
|
||||
commitSource: null,
|
||||
});
|
||||
|
||||
const normalizeRunId = (runId?: string | null): string => runId?.trim() ?? "";
|
||||
|
||||
const ensureRunState = (
|
||||
state: RuntimeTerminalState,
|
||||
runId: string
|
||||
): { state: RuntimeTerminalState; runState: RuntimeTerminalRunState } => {
|
||||
const existing = state.runStateByRun.get(runId);
|
||||
if (existing) return { state, runState: existing };
|
||||
const runStateByRun = new Map(state.runStateByRun);
|
||||
const created = emptyRunState();
|
||||
runStateByRun.set(runId, created);
|
||||
return {
|
||||
state: {
|
||||
runStateByRun,
|
||||
closedRunExpiresByRun: state.closedRunExpiresByRun,
|
||||
},
|
||||
runState: created,
|
||||
};
|
||||
};
|
||||
|
||||
export const clearRunTerminalState = (
|
||||
state: RuntimeTerminalState,
|
||||
input: { runId?: string | null }
|
||||
): RuntimeTerminalState => {
|
||||
const runId = normalizeRunId(input.runId);
|
||||
if (!runId) return state;
|
||||
if (!state.runStateByRun.has(runId)) return state;
|
||||
const runStateByRun = new Map(state.runStateByRun);
|
||||
runStateByRun.delete(runId);
|
||||
return {
|
||||
runStateByRun,
|
||||
closedRunExpiresByRun: state.closedRunExpiresByRun,
|
||||
};
|
||||
};
|
||||
|
||||
export const createRuntimeTerminalState = (): RuntimeTerminalState => ({
|
||||
runStateByRun: new Map<string, RuntimeTerminalRunState>(),
|
||||
closedRunExpiresByRun: new Map<string, number>(),
|
||||
});
|
||||
|
||||
export const applyTerminalCommit = (
|
||||
state: RuntimeTerminalState,
|
||||
input: {
|
||||
runId: string;
|
||||
source: RuntimeTerminalCommitSource;
|
||||
seq: number | null;
|
||||
}
|
||||
): RuntimeTerminalState => {
|
||||
const runId = normalizeRunId(input.runId);
|
||||
if (!runId) return state;
|
||||
const current = state.runStateByRun.get(runId) ?? emptyRunState();
|
||||
const next: RuntimeTerminalRunState = {
|
||||
...current,
|
||||
terminalCommitted: true,
|
||||
commitSource: input.source,
|
||||
chatFinalSeen: input.source === "chat-final" ? true : current.chatFinalSeen,
|
||||
lastTerminalSeq:
|
||||
typeof input.seq === "number" ? input.seq : current.lastTerminalSeq,
|
||||
};
|
||||
const runStateByRun = new Map(state.runStateByRun);
|
||||
runStateByRun.set(runId, next);
|
||||
return {
|
||||
runStateByRun,
|
||||
closedRunExpiresByRun: state.closedRunExpiresByRun,
|
||||
};
|
||||
};
|
||||
|
||||
export const deriveChatTerminalDecision = (input: {
|
||||
state: RuntimeTerminalState;
|
||||
runId?: string | null;
|
||||
isFinal: boolean;
|
||||
seq: number | null;
|
||||
}): ChatTerminalDecision => {
|
||||
const runId = normalizeRunId(input.runId);
|
||||
if (!input.isFinal || !runId) {
|
||||
return {
|
||||
state: input.state,
|
||||
commands: [],
|
||||
isStaleTerminal: false,
|
||||
fallbackCommittedBeforeFinal: false,
|
||||
lastTerminalSeqBeforeFinal: null,
|
||||
commitSourceBeforeFinal: null,
|
||||
};
|
||||
}
|
||||
|
||||
const ensured = ensureRunState(input.state, runId);
|
||||
const runState = ensured.runState;
|
||||
const fallbackCommittedBeforeFinal =
|
||||
runState.terminalCommitted && runState.commitSource === "lifecycle-fallback";
|
||||
const isStaleTerminal = (() => {
|
||||
if (!runState.terminalCommitted) return false;
|
||||
if (typeof input.seq !== "number") {
|
||||
return runState.commitSource === "chat-final";
|
||||
}
|
||||
if (typeof runState.lastTerminalSeq !== "number") return false;
|
||||
return input.seq <= runState.lastTerminalSeq;
|
||||
})();
|
||||
const runStateByRun = new Map(ensured.state.runStateByRun);
|
||||
runStateByRun.set(runId, {
|
||||
...runState,
|
||||
chatFinalSeen: true,
|
||||
});
|
||||
return {
|
||||
state: {
|
||||
runStateByRun,
|
||||
closedRunExpiresByRun: ensured.state.closedRunExpiresByRun,
|
||||
},
|
||||
commands: [{ kind: "cancelLifecycleFallback", runId }],
|
||||
isStaleTerminal,
|
||||
fallbackCommittedBeforeFinal,
|
||||
lastTerminalSeqBeforeFinal: runState.lastTerminalSeq,
|
||||
commitSourceBeforeFinal: runState.commitSource,
|
||||
};
|
||||
};
|
||||
|
||||
export const deriveLifecycleTerminalDecision = (
|
||||
input: LifecycleTerminalDecisionInput
|
||||
): LifecycleTerminalDecision => {
|
||||
const runId = normalizeRunId(input.runId);
|
||||
if (!runId) {
|
||||
return {
|
||||
state: input.state,
|
||||
commands: [],
|
||||
shouldCommitFallback: false,
|
||||
deferTransitionPatch: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.mode === "fallback-fired") {
|
||||
const runState = input.state.runStateByRun.get(runId);
|
||||
if (!runState || runState.chatFinalSeen) {
|
||||
return {
|
||||
state: input.state,
|
||||
commands: [],
|
||||
shouldCommitFallback: false,
|
||||
deferTransitionPatch: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: input.state,
|
||||
commands: [
|
||||
{ kind: "markRunClosed", runId },
|
||||
{ kind: "clearRunTracking", runId },
|
||||
],
|
||||
shouldCommitFallback: true,
|
||||
deferTransitionPatch: false,
|
||||
};
|
||||
}
|
||||
|
||||
const ensured = ensureRunState(input.state, runId);
|
||||
const runState = ensured.runState;
|
||||
const commands: RuntimeTerminalCommand[] = [];
|
||||
let state = ensured.state;
|
||||
let deferTransitionPatch = false;
|
||||
|
||||
const shouldScheduleFallback = input.phase === "end" && !runState.chatFinalSeen;
|
||||
if (shouldScheduleFallback) {
|
||||
if (input.fallbackFinalText) {
|
||||
commands.push({ kind: "cancelLifecycleFallback", runId });
|
||||
commands.push({
|
||||
kind: "scheduleLifecycleFallback",
|
||||
runId,
|
||||
delayMs: input.fallbackDelayMs,
|
||||
finalText: input.fallbackFinalText,
|
||||
});
|
||||
deferTransitionPatch = true;
|
||||
} else {
|
||||
commands.push({ kind: "clearRunTerminalState", runId });
|
||||
state = clearRunTerminalState(state, { runId });
|
||||
}
|
||||
} else if (input.hasPendingFallbackTimer) {
|
||||
commands.push({ kind: "cancelLifecycleFallback", runId });
|
||||
if (!runState.terminalCommitted && !runState.chatFinalSeen) {
|
||||
commands.push({ kind: "clearRunTerminalState", runId });
|
||||
state = clearRunTerminalState(state, { runId });
|
||||
}
|
||||
}
|
||||
|
||||
if (input.transitionClearsRunTracking && !deferTransitionPatch) {
|
||||
commands.push({ kind: "markRunClosed", runId });
|
||||
commands.push({ kind: "clearRunTracking", runId });
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
commands,
|
||||
shouldCommitFallback: false,
|
||||
deferTransitionPatch,
|
||||
};
|
||||
};
|
||||
|
||||
export const markClosedRun = (
|
||||
state: RuntimeTerminalState,
|
||||
input: { runId?: string | null; now: number; ttlMs: number }
|
||||
): RuntimeTerminalState => {
|
||||
const runId = normalizeRunId(input.runId);
|
||||
if (!runId) return state;
|
||||
const closedRunExpiresByRun = new Map(state.closedRunExpiresByRun);
|
||||
closedRunExpiresByRun.set(runId, input.now + input.ttlMs);
|
||||
return {
|
||||
runStateByRun: state.runStateByRun,
|
||||
closedRunExpiresByRun,
|
||||
};
|
||||
};
|
||||
|
||||
export const pruneClosedRuns = (
|
||||
state: RuntimeTerminalState,
|
||||
input: { at: number }
|
||||
): { state: RuntimeTerminalState; expiredRunIds: string[] } => {
|
||||
const expiredRunIds: string[] = [];
|
||||
const closedRunExpiresByRun = new Map(state.closedRunExpiresByRun);
|
||||
for (const [runId, expiresAt] of closedRunExpiresByRun.entries()) {
|
||||
if (expiresAt <= input.at) {
|
||||
closedRunExpiresByRun.delete(runId);
|
||||
expiredRunIds.push(runId);
|
||||
}
|
||||
}
|
||||
if (expiredRunIds.length === 0) {
|
||||
return { state, expiredRunIds };
|
||||
}
|
||||
const runStateByRun = new Map(state.runStateByRun);
|
||||
for (const runId of expiredRunIds) {
|
||||
runStateByRun.delete(runId);
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
runStateByRun,
|
||||
closedRunExpiresByRun,
|
||||
},
|
||||
expiredRunIds,
|
||||
};
|
||||
};
|
||||
|
||||
export const isClosedRun = (state: RuntimeTerminalState, runId?: string | null): boolean => {
|
||||
const key = normalizeRunId(runId);
|
||||
if (!key) return false;
|
||||
return state.closedRunExpiresByRun.has(key);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
isWebchatSessionMutationBlockedError,
|
||||
syncGatewaySessionSettings,
|
||||
type GatewayClient,
|
||||
type GatewaySessionsPatchResult,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
|
||||
type SessionSettingField = "model" | "thinkingLevel";
|
||||
|
||||
type AgentSessionState = {
|
||||
agentId: string;
|
||||
sessionCreated: boolean;
|
||||
model?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
};
|
||||
|
||||
type SessionSettingsDispatchAction =
|
||||
| {
|
||||
type: "updateAgent";
|
||||
agentId: string;
|
||||
patch: {
|
||||
model?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
sessionSettingsSynced?: boolean;
|
||||
sessionCreated?: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "appendOutput";
|
||||
agentId: string;
|
||||
line: string;
|
||||
};
|
||||
|
||||
type SessionSettingsDispatch = (action: SessionSettingsDispatchAction) => void;
|
||||
|
||||
export type ApplySessionSettingMutationParams = {
|
||||
agents: AgentSessionState[];
|
||||
dispatch: SessionSettingsDispatch;
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
field: SessionSettingField;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
const buildFallbackError = (field: SessionSettingField) =>
|
||||
field === "model" ? "Failed to set model." : "Failed to set thinking level.";
|
||||
|
||||
const buildErrorPrefix = (field: SessionSettingField) =>
|
||||
field === "model" ? "Model update failed" : "Thinking update failed";
|
||||
|
||||
const buildWebchatBlockedMessage = (field: SessionSettingField) =>
|
||||
field === "model"
|
||||
? "Model update not applied: this gateway blocks sessions.patch for WebChat clients; message sending still works."
|
||||
: "Thinking level update not applied: this gateway blocks sessions.patch for WebChat clients; message sending still works.";
|
||||
|
||||
export const applySessionSettingMutation = async ({
|
||||
agents,
|
||||
dispatch,
|
||||
client,
|
||||
agentId,
|
||||
sessionKey,
|
||||
field,
|
||||
value,
|
||||
}: ApplySessionSettingMutationParams) => {
|
||||
const targetAgent = agents.find((candidate) => candidate.agentId === agentId) ?? null;
|
||||
const previousModel = targetAgent?.model ?? null;
|
||||
const previousThinkingLevel = targetAgent?.thinkingLevel ?? null;
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: {
|
||||
[field]: value,
|
||||
sessionSettingsSynced: false,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await syncGatewaySessionSettings({
|
||||
client,
|
||||
sessionKey,
|
||||
...(field === "model" ? { model: value ?? null } : { thinkingLevel: value ?? null }),
|
||||
});
|
||||
const patch: {
|
||||
model?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
sessionSettingsSynced: boolean;
|
||||
sessionCreated: boolean;
|
||||
} = { sessionSettingsSynced: true, sessionCreated: true };
|
||||
if (field === "model") {
|
||||
const resolvedModel = resolveModelFromPatchResult(result);
|
||||
if (resolvedModel !== undefined) {
|
||||
patch.model = resolvedModel;
|
||||
}
|
||||
} else {
|
||||
const nextThinkingLevel =
|
||||
typeof result.entry?.thinkingLevel === "string" ? result.entry.thinkingLevel : undefined;
|
||||
if (nextThinkingLevel !== undefined) {
|
||||
patch.thinkingLevel = nextThinkingLevel;
|
||||
}
|
||||
}
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isWebchatSessionMutationBlockedError(err)) {
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: {
|
||||
...(field === "model"
|
||||
? { model: previousModel }
|
||||
: { thinkingLevel: previousThinkingLevel }),
|
||||
sessionSettingsSynced: true,
|
||||
sessionCreated: true,
|
||||
},
|
||||
});
|
||||
dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: buildWebchatBlockedMessage(field),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : buildFallbackError(field);
|
||||
dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: `${buildErrorPrefix(field)}: ${msg}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resolveModelFromPatchResult = (result: GatewaySessionsPatchResult): string | null | undefined => {
|
||||
const provider =
|
||||
typeof result.resolved?.modelProvider === "string" ? result.resolved.modelProvider.trim() : "";
|
||||
const model = typeof result.resolved?.model === "string" ? result.resolved.model.trim() : "";
|
||||
if (!provider || !model) return undefined;
|
||||
return `${provider}/${model}`;
|
||||
};
|
||||
@@ -0,0 +1,585 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useReducer,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
areTranscriptEntriesEqual,
|
||||
buildOutputLinesFromTranscriptEntries,
|
||||
buildTranscriptEntriesFromLines,
|
||||
createTranscriptEntryFromLine,
|
||||
sortTranscriptEntries,
|
||||
TRANSCRIPT_V2_ENABLED,
|
||||
type TranscriptAppendMeta,
|
||||
type TranscriptEntry,
|
||||
} from "@/features/agents/state/transcript";
|
||||
|
||||
export type AgentStatus = "idle" | "running" | "error";
|
||||
export type FocusFilter = "all" | "running" | "approvals";
|
||||
|
||||
export type AgentStoreSeed = {
|
||||
agentId: string;
|
||||
name: string;
|
||||
sessionKey: string;
|
||||
avatarSeed?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
model?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
sessionExecHost?: "sandbox" | "gateway" | "node";
|
||||
sessionExecSecurity?: "deny" | "allowlist" | "full";
|
||||
sessionExecAsk?: "off" | "on-miss" | "always";
|
||||
toolCallingEnabled?: boolean;
|
||||
showThinkingTraces?: boolean;
|
||||
};
|
||||
|
||||
export type AgentState = AgentStoreSeed & {
|
||||
status: AgentStatus;
|
||||
sessionCreated: boolean;
|
||||
awaitingUserInput: boolean;
|
||||
hasUnseenActivity: boolean;
|
||||
outputLines: string[];
|
||||
lastResult: string | null;
|
||||
lastDiff: string | null;
|
||||
runId: string | null;
|
||||
runStartedAt: number | null;
|
||||
streamText: string | null;
|
||||
thinkingTrace: string | null;
|
||||
latestOverride: string | null;
|
||||
latestOverrideKind: "heartbeat" | "cron" | null;
|
||||
lastAssistantMessageAt: number | null;
|
||||
lastActivityAt: number | null;
|
||||
latestPreview: string | null;
|
||||
lastUserMessage: string | null;
|
||||
draft: string;
|
||||
queuedMessages?: string[];
|
||||
sessionSettingsSynced: boolean;
|
||||
historyLoadedAt: number | null;
|
||||
historyFetchLimit: number | null;
|
||||
historyFetchedCount: number | null;
|
||||
historyMaybeTruncated: boolean;
|
||||
toolCallingEnabled: boolean;
|
||||
showThinkingTraces: boolean;
|
||||
transcriptEntries?: TranscriptEntry[];
|
||||
transcriptRevision?: number;
|
||||
transcriptSequenceCounter?: number;
|
||||
sessionEpoch?: number;
|
||||
lastHistoryRequestRevision?: number | null;
|
||||
lastAppliedHistoryRequestId?: string | null;
|
||||
};
|
||||
|
||||
export const buildNewSessionAgentPatch = (agent: AgentState): Partial<AgentState> => {
|
||||
return {
|
||||
sessionKey: agent.sessionKey,
|
||||
status: "idle",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
queuedMessages: [],
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
sessionCreated: true,
|
||||
sessionSettingsSynced: true,
|
||||
transcriptEntries: [],
|
||||
transcriptRevision: (agent.transcriptRevision ?? 0) + 1,
|
||||
transcriptSequenceCounter: 0,
|
||||
sessionEpoch: (agent.sessionEpoch ?? 0) + 1,
|
||||
lastHistoryRequestRevision: null,
|
||||
lastAppliedHistoryRequestId: null,
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentStoreState = {
|
||||
agents: AgentState[];
|
||||
selectedAgentId: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "hydrateAgents"; agents: AgentStoreSeed[]; selectedAgentId?: string }
|
||||
| { type: "setError"; error: string | null }
|
||||
| { type: "setLoading"; loading: boolean }
|
||||
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
| { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta }
|
||||
| { type: "enqueueQueuedMessage"; agentId: string; message: string }
|
||||
| { type: "removeQueuedMessage"; agentId: string; index: number }
|
||||
| { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string }
|
||||
| { type: "markActivity"; agentId: string; at?: number }
|
||||
| { type: "selectAgent"; agentId: string | null };
|
||||
|
||||
const initialState: AgentStoreState = {
|
||||
agents: [],
|
||||
selectedAgentId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const areStringArraysEqual = (left: string[], right: string[]): boolean => {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (left[i] !== right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const ensureTranscriptEntries = (agent: AgentState): TranscriptEntry[] => {
|
||||
if (Array.isArray(agent.transcriptEntries)) {
|
||||
return agent.transcriptEntries;
|
||||
}
|
||||
return buildTranscriptEntriesFromLines({
|
||||
lines: agent.outputLines,
|
||||
sessionKey: agent.sessionKey,
|
||||
source: "legacy",
|
||||
startSequence: 0,
|
||||
confirmed: true,
|
||||
});
|
||||
};
|
||||
|
||||
const nextTranscriptSequenceCounter = (
|
||||
currentCounter: number | undefined,
|
||||
entries: TranscriptEntry[]
|
||||
): number => {
|
||||
const derived = entries.reduce((max, entry) => Math.max(max, entry.sequenceKey + 1), 0);
|
||||
return Math.max(currentCounter ?? 0, derived);
|
||||
};
|
||||
|
||||
const createRuntimeAgentState = (
|
||||
seed: AgentStoreSeed,
|
||||
existing?: AgentState | null
|
||||
): AgentState => {
|
||||
const sameSessionKey = existing?.sessionKey === seed.sessionKey;
|
||||
const outputLines = sameSessionKey ? (existing?.outputLines ?? []) : [];
|
||||
const queuedMessages = sameSessionKey ? [...(existing?.queuedMessages ?? [])] : [];
|
||||
const transcriptEntries = sameSessionKey
|
||||
? Array.isArray(existing?.transcriptEntries)
|
||||
? existing.transcriptEntries
|
||||
: buildTranscriptEntriesFromLines({
|
||||
lines: outputLines,
|
||||
sessionKey: seed.sessionKey,
|
||||
source: "legacy",
|
||||
startSequence: 0,
|
||||
confirmed: true,
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...seed,
|
||||
avatarSeed: seed.avatarSeed ?? existing?.avatarSeed ?? seed.agentId,
|
||||
avatarUrl: seed.avatarUrl ?? existing?.avatarUrl ?? null,
|
||||
model: seed.model ?? existing?.model ?? null,
|
||||
thinkingLevel: seed.thinkingLevel ?? existing?.thinkingLevel ?? "high",
|
||||
sessionExecHost: seed.sessionExecHost ?? existing?.sessionExecHost,
|
||||
sessionExecSecurity: seed.sessionExecSecurity ?? existing?.sessionExecSecurity,
|
||||
sessionExecAsk: seed.sessionExecAsk ?? existing?.sessionExecAsk,
|
||||
status: sameSessionKey ? (existing?.status ?? "idle") : "idle",
|
||||
sessionCreated: sameSessionKey ? (existing?.sessionCreated ?? false) : false,
|
||||
awaitingUserInput: sameSessionKey ? (existing?.awaitingUserInput ?? false) : false,
|
||||
hasUnseenActivity: sameSessionKey ? (existing?.hasUnseenActivity ?? false) : false,
|
||||
outputLines,
|
||||
lastResult: sameSessionKey ? (existing?.lastResult ?? null) : null,
|
||||
lastDiff: sameSessionKey ? (existing?.lastDiff ?? null) : null,
|
||||
runId: sameSessionKey ? (existing?.runId ?? null) : null,
|
||||
runStartedAt: sameSessionKey ? (existing?.runStartedAt ?? null) : null,
|
||||
streamText: sameSessionKey ? (existing?.streamText ?? null) : null,
|
||||
thinkingTrace: sameSessionKey ? (existing?.thinkingTrace ?? null) : null,
|
||||
latestOverride: sameSessionKey ? (existing?.latestOverride ?? null) : null,
|
||||
latestOverrideKind: sameSessionKey ? (existing?.latestOverrideKind ?? null) : null,
|
||||
lastAssistantMessageAt: sameSessionKey ? (existing?.lastAssistantMessageAt ?? null) : null,
|
||||
lastActivityAt: sameSessionKey ? (existing?.lastActivityAt ?? null) : null,
|
||||
latestPreview: sameSessionKey ? (existing?.latestPreview ?? null) : null,
|
||||
lastUserMessage: sameSessionKey ? (existing?.lastUserMessage ?? null) : null,
|
||||
draft: sameSessionKey ? (existing?.draft ?? "") : "",
|
||||
queuedMessages,
|
||||
sessionSettingsSynced: sameSessionKey ? (existing?.sessionSettingsSynced ?? false) : false,
|
||||
historyLoadedAt: sameSessionKey ? (existing?.historyLoadedAt ?? null) : null,
|
||||
historyFetchLimit: sameSessionKey ? (existing?.historyFetchLimit ?? null) : null,
|
||||
historyFetchedCount: sameSessionKey ? (existing?.historyFetchedCount ?? null) : null,
|
||||
historyMaybeTruncated: sameSessionKey ? (existing?.historyMaybeTruncated ?? false) : false,
|
||||
toolCallingEnabled: seed.toolCallingEnabled ?? existing?.toolCallingEnabled ?? false,
|
||||
showThinkingTraces: seed.showThinkingTraces ?? existing?.showThinkingTraces ?? true,
|
||||
transcriptEntries,
|
||||
transcriptRevision: sameSessionKey
|
||||
? (existing?.transcriptRevision ?? outputLines.length)
|
||||
: 0,
|
||||
transcriptSequenceCounter: sameSessionKey
|
||||
? (existing?.transcriptSequenceCounter ??
|
||||
nextTranscriptSequenceCounter(existing?.transcriptSequenceCounter, transcriptEntries))
|
||||
: 0,
|
||||
sessionEpoch: sameSessionKey
|
||||
? (existing?.sessionEpoch ?? 0)
|
||||
: (existing?.sessionEpoch ?? 0) + 1,
|
||||
lastHistoryRequestRevision: sameSessionKey
|
||||
? (existing?.lastHistoryRequestRevision ?? null)
|
||||
: null,
|
||||
lastAppliedHistoryRequestId: sameSessionKey
|
||||
? (existing?.lastAppliedHistoryRequestId ?? null)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
const reducer = (state: AgentStoreState, action: Action): AgentStoreState => {
|
||||
switch (action.type) {
|
||||
case "hydrateAgents": {
|
||||
const byId = new Map(state.agents.map((agent) => [agent.agentId, agent]));
|
||||
const agents = action.agents.map((seed) =>
|
||||
createRuntimeAgentState(seed, byId.get(seed.agentId))
|
||||
);
|
||||
const requestedSelectedAgentId = action.selectedAgentId?.trim() ?? "";
|
||||
const selectedAgentId =
|
||||
requestedSelectedAgentId &&
|
||||
agents.some((agent) => agent.agentId === requestedSelectedAgentId)
|
||||
? requestedSelectedAgentId
|
||||
: state.selectedAgentId &&
|
||||
agents.some((agent) => agent.agentId === state.selectedAgentId)
|
||||
? state.selectedAgentId
|
||||
: agents[0]?.agentId ?? null;
|
||||
return {
|
||||
...state,
|
||||
agents,
|
||||
selectedAgentId,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
case "setError":
|
||||
return { ...state, error: action.error, loading: false };
|
||||
case "setLoading":
|
||||
return { ...state, loading: action.loading };
|
||||
case "updateAgent":
|
||||
return {
|
||||
...state,
|
||||
agents: state.agents.map((agent) => {
|
||||
if (agent.agentId !== action.agentId) return agent;
|
||||
const patch = action.patch;
|
||||
const nextSessionKey = (patch.sessionKey ?? agent.sessionKey).trim();
|
||||
const sessionKeyChanged = nextSessionKey !== agent.sessionKey.trim();
|
||||
const patchHasTranscriptEntries = Array.isArray(patch.transcriptEntries);
|
||||
const patchHasOutputLines = Array.isArray(patch.outputLines);
|
||||
const patchMutatesTranscript = patchHasTranscriptEntries || patchHasOutputLines;
|
||||
|
||||
const existingEntries = ensureTranscriptEntries(agent);
|
||||
const base: AgentState = { ...agent, ...patch };
|
||||
let nextEntries: TranscriptEntry[] = existingEntries;
|
||||
if (Array.isArray(base.transcriptEntries)) {
|
||||
nextEntries = base.transcriptEntries as TranscriptEntry[];
|
||||
}
|
||||
let nextOutputLines: string[] = agent.outputLines;
|
||||
if (Array.isArray(base.outputLines)) {
|
||||
nextOutputLines = base.outputLines as string[];
|
||||
}
|
||||
let transcriptMutated = false;
|
||||
|
||||
if (patchHasTranscriptEntries) {
|
||||
const patchedTranscriptEntries = patch.transcriptEntries as TranscriptEntry[];
|
||||
const normalized = TRANSCRIPT_V2_ENABLED
|
||||
? sortTranscriptEntries(patchedTranscriptEntries)
|
||||
: [...patchedTranscriptEntries];
|
||||
transcriptMutated = !areTranscriptEntriesEqual(existingEntries, normalized);
|
||||
nextEntries = normalized;
|
||||
nextOutputLines = buildOutputLinesFromTranscriptEntries(normalized);
|
||||
} else if (patchHasOutputLines) {
|
||||
const patchedOutputLines = patch.outputLines as string[];
|
||||
const rebuilt = buildTranscriptEntriesFromLines({
|
||||
lines: patchedOutputLines,
|
||||
sessionKey: nextSessionKey || agent.sessionKey,
|
||||
source: "legacy",
|
||||
startSequence: 0,
|
||||
confirmed: true,
|
||||
});
|
||||
const normalized = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(rebuilt) : rebuilt;
|
||||
transcriptMutated = !areStringArraysEqual(agent.outputLines, patchedOutputLines);
|
||||
nextEntries = normalized;
|
||||
nextOutputLines = TRANSCRIPT_V2_ENABLED
|
||||
? buildOutputLinesFromTranscriptEntries(normalized)
|
||||
: [...patchedOutputLines];
|
||||
}
|
||||
|
||||
const revision = transcriptMutated
|
||||
? (agent.transcriptRevision ?? 0) + 1
|
||||
: (patch.transcriptRevision ?? agent.transcriptRevision ?? 0);
|
||||
const nextCounter = patchMutatesTranscript
|
||||
? nextTranscriptSequenceCounter(base.transcriptSequenceCounter, nextEntries)
|
||||
: (base.transcriptSequenceCounter ?? agent.transcriptSequenceCounter ?? 0);
|
||||
|
||||
return {
|
||||
...base,
|
||||
outputLines: nextOutputLines,
|
||||
transcriptEntries: nextEntries,
|
||||
transcriptRevision: revision,
|
||||
transcriptSequenceCounter: nextCounter,
|
||||
sessionEpoch:
|
||||
patch.sessionEpoch !== undefined
|
||||
? patch.sessionEpoch
|
||||
: sessionKeyChanged
|
||||
? (agent.sessionEpoch ?? 0) + 1
|
||||
: (agent.sessionEpoch ?? 0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
case "appendOutput":
|
||||
return {
|
||||
...state,
|
||||
agents: state.agents.map((agent) => {
|
||||
if (agent.agentId !== action.agentId) return agent;
|
||||
const existingEntries = ensureTranscriptEntries(agent);
|
||||
const nextSequence = nextTranscriptSequenceCounter(
|
||||
agent.transcriptSequenceCounter,
|
||||
existingEntries
|
||||
);
|
||||
const nextEntry = createTranscriptEntryFromLine({
|
||||
line: action.line,
|
||||
sessionKey: action.transcript?.sessionKey ?? agent.sessionKey,
|
||||
source: action.transcript?.source ?? "legacy",
|
||||
runId: action.transcript?.runId ?? agent.runId,
|
||||
timestampMs: action.transcript?.timestampMs,
|
||||
fallbackTimestampMs: action.transcript?.timestampMs ?? Date.now(),
|
||||
role: action.transcript?.role,
|
||||
kind: action.transcript?.kind,
|
||||
entryId: action.transcript?.entryId,
|
||||
confirmed: action.transcript?.confirmed,
|
||||
sequenceKey: nextSequence,
|
||||
});
|
||||
if (!nextEntry) {
|
||||
return { ...agent, outputLines: [...agent.outputLines, action.line] };
|
||||
}
|
||||
const nextEntryId = nextEntry.entryId.trim();
|
||||
const existingIndex =
|
||||
nextEntryId.length > 0
|
||||
? existingEntries.findIndex((entry) => entry.entryId === nextEntryId)
|
||||
: -1;
|
||||
const hasReplacement = existingIndex >= 0;
|
||||
|
||||
let nextEntries: TranscriptEntry[];
|
||||
if (hasReplacement) {
|
||||
let replacedOne = false;
|
||||
const replaced = existingEntries.reduce<TranscriptEntry[]>((acc, entry) => {
|
||||
if (entry.entryId !== nextEntryId) {
|
||||
acc.push(entry);
|
||||
return acc;
|
||||
}
|
||||
if (replacedOne) {
|
||||
return acc;
|
||||
}
|
||||
replacedOne = true;
|
||||
acc.push({
|
||||
...nextEntry,
|
||||
sequenceKey: entry.sequenceKey,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
nextEntries = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(replaced) : replaced;
|
||||
} else {
|
||||
const appended = [...existingEntries, nextEntry];
|
||||
nextEntries = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(appended) : appended;
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
outputLines:
|
||||
TRANSCRIPT_V2_ENABLED || hasReplacement
|
||||
? buildOutputLinesFromTranscriptEntries(nextEntries)
|
||||
: [...agent.outputLines, action.line],
|
||||
transcriptEntries: nextEntries,
|
||||
transcriptRevision: (agent.transcriptRevision ?? 0) + 1,
|
||||
transcriptSequenceCounter: Math.max(
|
||||
agent.transcriptSequenceCounter ?? 0,
|
||||
nextEntry.sequenceKey + 1
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
case "enqueueQueuedMessage":
|
||||
return {
|
||||
...state,
|
||||
agents: state.agents.map((agent) => {
|
||||
if (agent.agentId !== action.agentId) return agent;
|
||||
const message = action.message.trim();
|
||||
if (!message) return agent;
|
||||
const queuedMessages = [...(agent.queuedMessages ?? []), message];
|
||||
return { ...agent, queuedMessages };
|
||||
}),
|
||||
};
|
||||
case "removeQueuedMessage":
|
||||
return {
|
||||
...state,
|
||||
agents: state.agents.map((agent) => {
|
||||
if (agent.agentId !== action.agentId) return agent;
|
||||
if (!Number.isInteger(action.index) || action.index < 0) return agent;
|
||||
const queuedMessages = agent.queuedMessages ?? [];
|
||||
if (action.index >= queuedMessages.length) return agent;
|
||||
return {
|
||||
...agent,
|
||||
queuedMessages: queuedMessages.filter((_, index) => index !== action.index),
|
||||
};
|
||||
}),
|
||||
};
|
||||
case "shiftQueuedMessage":
|
||||
return {
|
||||
...state,
|
||||
agents: state.agents.map((agent) => {
|
||||
if (agent.agentId !== action.agentId) return agent;
|
||||
const queuedMessages = agent.queuedMessages ?? [];
|
||||
if (queuedMessages.length === 0) return agent;
|
||||
if (
|
||||
action.expectedMessage !== undefined &&
|
||||
action.expectedMessage.trim() !== queuedMessages[0]
|
||||
) {
|
||||
return agent;
|
||||
}
|
||||
return { ...agent, queuedMessages: queuedMessages.slice(1) };
|
||||
}),
|
||||
};
|
||||
case "markActivity": {
|
||||
const at = action.at ?? Date.now();
|
||||
return {
|
||||
...state,
|
||||
agents: state.agents.map((agent) => {
|
||||
if (agent.agentId !== action.agentId) return agent;
|
||||
const isSelected = state.selectedAgentId === action.agentId;
|
||||
return {
|
||||
...agent,
|
||||
lastActivityAt: at,
|
||||
hasUnseenActivity: isSelected ? false : true,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "selectAgent": {
|
||||
if (action.agentId === state.selectedAgentId) {
|
||||
if (action.agentId === null) {
|
||||
return state;
|
||||
}
|
||||
const selected = state.agents.find((agent) => agent.agentId === action.agentId) ?? null;
|
||||
if (!selected || !selected.hasUnseenActivity) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
selectedAgentId: action.agentId,
|
||||
agents:
|
||||
action.agentId === null
|
||||
? state.agents
|
||||
: state.agents.map((agent) =>
|
||||
agent.agentId === action.agentId
|
||||
? { ...agent, hasUnseenActivity: false }
|
||||
: agent
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const agentStoreReducer = reducer;
|
||||
export const initialAgentStoreState = initialState;
|
||||
|
||||
type AgentStoreContextValue = {
|
||||
state: AgentStoreState;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
hydrateAgents: (agents: AgentStoreSeed[], selectedAgentId?: string) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
};
|
||||
|
||||
const AgentStoreContext = createContext<AgentStoreContextValue | null>(null);
|
||||
|
||||
export const AgentStoreProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const hydrateAgents = useCallback(
|
||||
(agents: AgentStoreSeed[], selectedAgentId?: string) => {
|
||||
dispatch({ type: "hydrateAgents", agents, selectedAgentId });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setLoading = useCallback(
|
||||
(loading: boolean) => dispatch({ type: "setLoading", loading }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setError = useCallback(
|
||||
(error: string | null) => dispatch({ type: "setError", error }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ state, dispatch, hydrateAgents, setLoading, setError }),
|
||||
[dispatch, hydrateAgents, setError, setLoading, state]
|
||||
);
|
||||
|
||||
return (
|
||||
<AgentStoreContext.Provider value={value}>{children}</AgentStoreContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAgentStore = () => {
|
||||
const ctx = useContext(AgentStoreContext);
|
||||
if (!ctx) {
|
||||
throw new Error("AgentStoreProvider is missing.");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const getSelectedAgent = (state: AgentStoreState): AgentState | null => {
|
||||
if (!state.selectedAgentId) return null;
|
||||
return state.agents.find((agent) => agent.agentId === state.selectedAgentId) ?? null;
|
||||
};
|
||||
|
||||
export const getFilteredAgents = (state: AgentStoreState, filter: FocusFilter): AgentState[] => {
|
||||
const statusPriority: Record<AgentStatus, number> = {
|
||||
running: 0,
|
||||
idle: 1,
|
||||
error: 2,
|
||||
};
|
||||
const getActivityTimestamp = (agent: AgentState) =>
|
||||
Math.max(agent.lastActivityAt ?? 0, agent.runStartedAt ?? 0, agent.lastAssistantMessageAt ?? 0);
|
||||
const sortAgents = (agents: AgentState[], prioritizeStatus: boolean) =>
|
||||
agents
|
||||
.map((agent, index) => ({ agent, index }))
|
||||
.sort((left, right) => {
|
||||
if (prioritizeStatus) {
|
||||
const statusDelta =
|
||||
statusPriority[left.agent.status] - statusPriority[right.agent.status];
|
||||
if (statusDelta !== 0) return statusDelta;
|
||||
}
|
||||
const timeDelta = getActivityTimestamp(right.agent) - getActivityTimestamp(left.agent);
|
||||
if (timeDelta !== 0) return timeDelta;
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map(({ agent }) => agent);
|
||||
switch (filter) {
|
||||
case "all":
|
||||
return sortAgents(state.agents, true);
|
||||
case "running":
|
||||
return sortAgents(state.agents.filter((agent) => agent.status === "running"), false);
|
||||
case "approvals":
|
||||
return sortAgents(state.agents.filter((agent) => agent.awaitingUserInput), false);
|
||||
default: {
|
||||
const _exhaustive: never = filter;
|
||||
void _exhaustive;
|
||||
return sortAgents(state.agents, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user