First Release of Claw3D (#11)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-19 23:14:04 -05:00
committed by GitHub
parent 5ea96b2650
commit 4fa4f13558
431 changed files with 105438 additions and 14 deletions
+5
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AgentsPage() {
redirect("/office");
}
+121
View File
@@ -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 });
}
}
+216
View File
@@ -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 });
}
}
+194
View File
@@ -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 });
}
}
+56
View File
@@ -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 });
}
}
+120
View File
@@ -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 });
}
}
+17
View File
@@ -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 });
}
}
+38
View File
@@ -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 });
}
}
+88
View File
@@ -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 });
}
}
+61
View File
@@ -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 });
}
}
+55
View File
@@ -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 });
}
}
+47
View File
@@ -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 });
}
}
+121
View File
@@ -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 });
}
}
+53
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -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>
);
}
+21
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/office");
}
+196
View File
@@ -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;
}
+57
View File
@@ -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);
};
+17
View File
@@ -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://&lt;your-tailnet-host&gt;</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>
);
};
+493
View File
@@ -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
+4
View File
@@ -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}`;
};
+585
View File
@@ -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