Improve ExecPlan accuracy

This commit is contained in:
George Pickett
2026-02-24 10:23:46 -08:00
parent 27ff84ba92
commit e042b14c03
47 changed files with 11871 additions and 2379 deletions
+384 -1442
View File
File diff suppressed because it is too large Load Diff
@@ -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,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,125 @@
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";
type CronActionKind = "run-cron-job" | "delete-cron-job";
export type AgentSettingsMutationRequest =
| { kind: GuardedActionKind; agentId: 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";
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";
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,
};
}
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,125 @@
import {
type AgentPermissionsDraft,
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;
};
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,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,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,260 @@
export type SettingsRouteTab = "personality" | "capabilities" | "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 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 buildSettingsRouteHref = (agentId: string): string => {
const resolved = agentId.trim();
if (!resolved) {
throw new Error("Cannot build settings route href: agent id is empty.");
}
return `/agents/${encodeURIComponent(resolved)}/settings`;
};
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,260 @@
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 } 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 | 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 | 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,157 @@
import type { FocusFilter } from "@/features/agents/state/store";
import {
resolveFocusedPreference,
type StudioSettings,
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 | 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",
};
}
return {
preferredSelectedAgentId: preference.selectedAgentId,
focusFilter: preference.filter,
};
}
@@ -0,0 +1,484 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation";
import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation";
import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation";
import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation";
import {
planAgentSettingsMutation,
type AgentSettingsMutationContext,
} from "@/features/agents/operations/agentSettingsMutationWorkflow";
import {
buildQueuedMutationBlock,
runAgentConfigMutationLifecycle,
type MutationBlockState,
type MutationWorkflowKind,
} from "@/features/agents/operations/mutationLifecycleWorkflow";
import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue";
import { useGatewayRestartBlock } from "@/features/agents/operations/useGatewayRestartBlock";
import type { AgentState } from "@/features/agents/state/store";
import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder";
import {
filterCronJobsForAgent,
listCronJobs,
removeCronJob,
runCronJobNow,
sortCronJobsByUpdatedAt,
type CronJobSummary,
} from "@/lib/cron/types";
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient";
import { shouldAwaitDisconnectRestartForRemoteMutation } from "@/lib/gateway/gatewayReloadMode";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import { renameGatewayAgent } from "@/lib/gateway/agentConfig";
import { fetchJson } from "@/lib/http";
export type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind };
type AgentForSettingsMutation = Pick<AgentState, "agentId" | "name" | "sessionKey">;
export type UseAgentSettingsMutationControllerParams = {
client: GatewayClient;
status: GatewayStatus;
isLocalGateway: boolean;
agents: AgentForSettingsMutation[];
hasCreateBlock: boolean;
enqueueConfigMutation: (params: {
kind: ConfigMutationKind;
label: string;
run: () => Promise<void>;
}) => Promise<void>;
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
settingsRouteActive: boolean;
inspectSidebarAgentId: string | null;
inspectSidebarTab: string | null;
loadAgents: () => Promise<void>;
refreshGatewayConfigSnapshot: () => Promise<GatewayModelPolicySnapshot | null>;
clearInspectSidebar: () => void;
setInspectSidebarCapabilities: (agentId: string) => void;
dispatchUpdateAgent: (agentId: string, patch: Partial<AgentState>) => void;
setMobilePaneChat: () => void;
setError: (message: string) => void;
};
export function useAgentSettingsMutationController(params: UseAgentSettingsMutationControllerParams) {
const [settingsCronJobs, setSettingsCronJobs] = useState<CronJobSummary[]>([]);
const [settingsCronLoading, setSettingsCronLoading] = useState(false);
const [settingsCronError, setSettingsCronError] = useState<string | null>(null);
const [cronCreateBusy, setCronCreateBusy] = useState(false);
const [cronRunBusyJobId, setCronRunBusyJobId] = useState<string | null>(null);
const [cronDeleteBusyJobId, setCronDeleteBusyJobId] = useState<string | null>(null);
const [restartingMutationBlock, setRestartingMutationBlock] =
useState<RestartingMutationBlockState | null>(null);
const hasRenameMutationBlock = restartingMutationBlock?.kind === "rename-agent";
const hasDeleteMutationBlock = restartingMutationBlock?.kind === "delete-agent";
const hasRestartBlockInProgress = Boolean(
restartingMutationBlock && restartingMutationBlock.phase !== "queued"
);
const mutationContext: AgentSettingsMutationContext = useMemo(
() => ({
status: params.status,
hasCreateBlock: params.hasCreateBlock,
hasRenameBlock: hasRenameMutationBlock,
hasDeleteBlock: hasDeleteMutationBlock,
cronCreateBusy,
cronRunBusyJobId,
cronDeleteBusyJobId,
}),
[
cronCreateBusy,
cronDeleteBusyJobId,
cronRunBusyJobId,
hasDeleteMutationBlock,
hasRenameMutationBlock,
params.hasCreateBlock,
params.status,
]
);
const loadCronJobsForSettingsAgent = useCallback(
async (agentId: string) => {
const resolvedAgentId = agentId.trim();
if (!resolvedAgentId) {
setSettingsCronJobs([]);
setSettingsCronError("Failed to load schedules: missing agent id.");
return;
}
setSettingsCronLoading(true);
setSettingsCronError(null);
try {
const result = await listCronJobs(params.client, { includeDisabled: true });
const filtered = filterCronJobsForAgent(result.jobs, resolvedAgentId);
setSettingsCronJobs(sortCronJobsByUpdatedAt(filtered));
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load schedules.";
setSettingsCronJobs([]);
setSettingsCronError(message);
if (!isGatewayDisconnectLikeError(err)) {
console.error(message);
}
} finally {
setSettingsCronLoading(false);
}
},
[params.client]
);
useEffect(() => {
if (
!params.settingsRouteActive ||
!params.inspectSidebarAgentId ||
params.status !== "connected" ||
params.inspectSidebarTab !== "automations"
) {
setSettingsCronJobs([]);
setSettingsCronLoading(false);
setSettingsCronError(null);
setCronRunBusyJobId(null);
setCronDeleteBusyJobId(null);
return;
}
void loadCronJobsForSettingsAgent(params.inspectSidebarAgentId);
}, [
loadCronJobsForSettingsAgent,
params.inspectSidebarAgentId,
params.inspectSidebarTab,
params.settingsRouteActive,
params.status,
]);
const runRestartingMutationLifecycle = useCallback(
async (input: {
kind: MutationWorkflowKind;
agentId: string;
agentName: string;
label: string;
executeMutation: () => Promise<void>;
}) => {
return await runAgentConfigMutationLifecycle({
kind: input.kind,
label: input.label,
isLocalGateway: params.isLocalGateway,
deps: {
enqueueConfigMutation: params.enqueueConfigMutation,
setQueuedBlock: () => {
const queuedBlock = buildQueuedMutationBlock({
kind: input.kind,
agentId: input.agentId,
agentName: input.agentName,
startedAt: Date.now(),
});
setRestartingMutationBlock({
kind: input.kind,
agentId: queuedBlock.agentId,
agentName: queuedBlock.agentName,
phase: queuedBlock.phase,
startedAt: queuedBlock.startedAt,
sawDisconnect: queuedBlock.sawDisconnect,
});
},
setMutatingBlock: () => {
setRestartingMutationBlock((current) => {
if (!current) return current;
if (current.kind !== input.kind || current.agentId !== input.agentId) return current;
return {
...current,
phase: "mutating",
};
});
},
patchBlockAwaitingRestart: (patch) => {
setRestartingMutationBlock((current) => {
if (!current) return current;
if (current.kind !== input.kind || current.agentId !== input.agentId) return current;
return {
...current,
...patch,
};
});
},
clearBlock: () => {
setRestartingMutationBlock((current) => {
if (!current) return current;
if (current.kind !== input.kind || current.agentId !== input.agentId) return current;
return null;
});
},
executeMutation: input.executeMutation,
shouldAwaitRemoteRestart: async () =>
shouldAwaitDisconnectRestartForRemoteMutation({
client: params.client,
cachedConfigSnapshot: params.gatewayConfigSnapshot,
logError: (message, error) => console.error(message, error),
}),
reloadAgents: params.loadAgents,
setMobilePaneChat: params.setMobilePaneChat,
onError: params.setError,
},
});
},
[
params.client,
params.enqueueConfigMutation,
params.gatewayConfigSnapshot,
params.isLocalGateway,
params.loadAgents,
params.setError,
params.setMobilePaneChat,
]
);
useGatewayRestartBlock({
status: params.status,
block: restartingMutationBlock,
setBlock: setRestartingMutationBlock,
maxWaitMs: 90_000,
onTimeout: () => {
const timeoutMessage =
restartingMutationBlock?.kind === "delete-agent"
? "Gateway restart timed out after deleting the agent."
: "Gateway restart timed out after renaming the agent.";
setRestartingMutationBlock(null);
params.setError(timeoutMessage);
},
onRestartComplete: async (_, ctx) => {
await params.loadAgents();
if (ctx.isCancelled()) return;
setRestartingMutationBlock(null);
params.setMobilePaneChat();
},
});
const handleDeleteAgent = useCallback(
async (agentId: string) => {
const decision = planAgentSettingsMutation(
{ kind: "delete-agent", agentId },
mutationContext
);
if (decision.kind === "deny") {
if (decision.message) {
params.setError(decision.message);
}
return;
}
const agent = params.agents.find((entry) => entry.agentId === decision.normalizedAgentId);
if (!agent) return;
const confirmed = window.confirm(
`Delete ${agent.name}? This removes the agent from gateway config + scheduled automations and moves its workspace/state into ~/.openclaw/trash on the gateway host.`
);
if (!confirmed) return;
await runRestartingMutationLifecycle({
kind: "delete-agent",
agentId: decision.normalizedAgentId,
agentName: agent.name,
label: `Delete ${agent.name}`,
executeMutation: async () => {
await deleteAgentViaStudio({
client: params.client,
agentId: decision.normalizedAgentId,
fetchJson,
logError: (message, error) => console.error(message, error),
});
params.clearInspectSidebar();
},
});
},
[mutationContext, params, runRestartingMutationLifecycle]
);
const handleCreateCronJob = useCallback(
async (agentId: string, draft: CronCreateDraft) => {
const decision = planAgentSettingsMutation(
{ kind: "create-cron-job", agentId },
mutationContext
);
if (decision.kind === "deny") {
if (decision.message) {
setSettingsCronError(decision.message);
}
return;
}
try {
await performCronCreateFlow({
client: params.client,
agentId: decision.normalizedAgentId,
draft,
busy: {
createBusy: cronCreateBusy,
runBusyJobId: cronRunBusyJobId,
deleteBusyJobId: cronDeleteBusyJobId,
},
onBusyChange: setCronCreateBusy,
onError: setSettingsCronError,
onJobs: setSettingsCronJobs,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create automation.";
if (!isGatewayDisconnectLikeError(err)) {
console.error(message);
}
throw err;
}
},
[cronCreateBusy, cronDeleteBusyJobId, cronRunBusyJobId, mutationContext, params.client]
);
const handleRunCronJob = useCallback(
async (agentId: string, jobId: string) => {
const decision = planAgentSettingsMutation(
{ kind: "run-cron-job", agentId, jobId },
mutationContext
);
if (decision.kind === "deny") {
if (decision.message) {
setSettingsCronError(decision.message);
}
return;
}
const resolvedJobId = decision.normalizedJobId as string;
const resolvedAgentId = decision.normalizedAgentId;
setCronRunBusyJobId(resolvedJobId);
setSettingsCronError(null);
try {
await runCronJobNow(params.client, resolvedJobId);
await loadCronJobsForSettingsAgent(resolvedAgentId);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to run schedule.";
setSettingsCronError(message);
console.error(message);
} finally {
setCronRunBusyJobId((current) => (current === resolvedJobId ? null : current));
}
},
[loadCronJobsForSettingsAgent, mutationContext, params.client]
);
const handleDeleteCronJob = useCallback(
async (agentId: string, jobId: string) => {
const decision = planAgentSettingsMutation(
{ kind: "delete-cron-job", agentId, jobId },
mutationContext
);
if (decision.kind === "deny") {
if (decision.message) {
setSettingsCronError(decision.message);
}
return;
}
const resolvedJobId = decision.normalizedJobId as string;
const resolvedAgentId = decision.normalizedAgentId;
setCronDeleteBusyJobId(resolvedJobId);
setSettingsCronError(null);
try {
const result = await removeCronJob(params.client, resolvedJobId);
if (result.ok && result.removed) {
setSettingsCronJobs((jobs) => jobs.filter((job) => job.id !== resolvedJobId));
}
await loadCronJobsForSettingsAgent(resolvedAgentId);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to delete schedule.";
setSettingsCronError(message);
console.error(message);
} finally {
setCronDeleteBusyJobId((current) => (current === resolvedJobId ? null : current));
}
},
[loadCronJobsForSettingsAgent, mutationContext, params.client]
);
const handleRenameAgent = useCallback(
async (agentId: string, name: string) => {
const decision = planAgentSettingsMutation(
{ kind: "rename-agent", agentId },
mutationContext
);
if (decision.kind === "deny") {
if (decision.message) {
params.setError(decision.message);
}
return false;
}
const agent = params.agents.find((entry) => entry.agentId === decision.normalizedAgentId);
if (!agent) return false;
return await runRestartingMutationLifecycle({
kind: "rename-agent",
agentId: decision.normalizedAgentId,
agentName: name,
label: `Rename ${agent.name}`,
executeMutation: async () => {
await renameGatewayAgent({
client: params.client,
agentId: decision.normalizedAgentId,
name,
});
params.dispatchUpdateAgent(decision.normalizedAgentId, { name });
},
});
},
[mutationContext, params, runRestartingMutationLifecycle]
);
const handleUpdateAgentPermissions = useCallback(
async (agentId: string, draft: AgentPermissionsDraft) => {
const decision = planAgentSettingsMutation(
{ kind: "update-agent-permissions", agentId },
mutationContext
);
if (decision.kind === "deny") {
if (decision.message) {
params.setError(decision.message);
}
return;
}
const agent = params.agents.find((entry) => entry.agentId === decision.normalizedAgentId);
if (!agent) return;
await params.enqueueConfigMutation({
kind: "update-agent-permissions",
label: `Update permissions for ${agent.name}`,
run: async () => {
await updateAgentPermissionsViaStudio({
client: params.client,
agentId: decision.normalizedAgentId,
sessionKey: agent.sessionKey,
draft,
loadAgents: async () => {},
});
await params.loadAgents();
await params.refreshGatewayConfigSnapshot();
params.setInspectSidebarCapabilities(decision.normalizedAgentId);
params.setMobilePaneChat();
},
});
},
[mutationContext, params]
);
return {
settingsCronJobs,
settingsCronLoading,
settingsCronError,
cronCreateBusy,
cronRunBusyJobId,
cronDeleteBusyJobId,
restartingMutationBlock,
hasRenameMutationBlock,
hasDeleteMutationBlock,
hasRestartBlockInProgress,
handleDeleteAgent,
handleCreateCronJob,
handleRunCronJob,
handleDeleteCronJob,
handleRenameAgent,
handleUpdateAgentPermissions,
};
}
@@ -0,0 +1,302 @@
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 GatewayClientLike = {
call: (method: string, params: unknown) => Promise<unknown>;
};
export type UseChatInteractionControllerParams = {
client: GatewayClientLike;
status: GatewayStatus;
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>;
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 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);
clearPendingLivePatch(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 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,
handleNewSession,
handleStopRun,
queueLivePatch,
clearPendingLivePatch,
};
}
@@ -0,0 +1,188 @@
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>;
}) => 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 logError = params.logError ?? defaultLogError;
const refreshGatewayConfigSnapshot = useCallback(async () => {
if (params.status !== "connected") return null;
try {
const snapshot = await params.client.call<GatewayModelPolicySnapshot>("config.get", {});
params.setGatewayConfigSnapshot(snapshot);
return snapshot;
} catch (err) {
if (!params.isDisconnectLikeError(err)) {
logError("Failed to refresh gateway config.", err);
}
return null;
}
}, [
params.client,
params.isDisconnectLikeError,
params.setGatewayConfigSnapshot,
params.status,
logError,
]);
useEffect(() => {
const repairIntent = resolveSandboxRepairIntent({
status: params.status,
attempted: sandboxRepairAttemptedRef.current,
snapshot: params.gatewayConfigSnapshot,
});
if (repairIntent.kind !== "repair") return;
sandboxRepairAttemptedRef.current = true;
void params.enqueueConfigMutation({
kind: "repair-sandbox-tool-allowlist",
label: "Repair sandbox tool access",
run: async () => {
for (const agentId of repairIntent.agentIds) {
await updateGatewayAgentOverrides({
client: params.client,
agentId,
overrides: {
tools: {
sandbox: {
tools: {
allow: ["*"],
},
},
},
},
});
}
await params.loadAgents();
},
});
}, [
params.client,
params.enqueueConfigMutation,
params.gatewayConfigSnapshot,
params.loadAgents,
params.status,
]);
useEffect(() => {
if (
!shouldRefreshGatewayConfigForSettingsRoute({
status: params.status,
settingsRouteActive: params.settingsRouteActive,
inspectSidebarAgentId: params.inspectSidebarAgentId,
})
) {
return;
}
void refreshGatewayConfigSnapshot();
}, [
params.inspectSidebarAgentId,
params.settingsRouteActive,
params.status,
refreshGatewayConfigSnapshot,
]);
useEffect(() => {
const syncIntent = resolveGatewayModelsSyncIntent({ status: params.status });
if (syncIntent.kind === "clear") {
params.setGatewayModels([]);
params.setGatewayModelsError(null);
params.setGatewayConfigSnapshot(null);
return;
}
let cancelled = false;
const loadModels = async () => {
let configSnapshot: GatewayModelPolicySnapshot | null = null;
try {
configSnapshot = await params.client.call<GatewayModelPolicySnapshot>("config.get", {});
if (!cancelled) {
params.setGatewayConfigSnapshot(configSnapshot);
}
} catch (err) {
if (!params.isDisconnectLikeError(err)) {
logError("Failed to load gateway config.", err);
}
}
try {
const result = await params.client.call<{ models: GatewayModelChoice[] }>(
"models.list",
{}
);
if (cancelled) return;
const catalog = Array.isArray(result.models) ? result.models : [];
params.setGatewayModels(buildGatewayModelChoices(catalog, configSnapshot));
params.setGatewayModelsError(null);
} catch (err) {
if (cancelled) return;
const message = err instanceof Error ? err.message : "Failed to load models.";
params.setGatewayModelsError(message);
params.setGatewayModels([]);
if (!params.isDisconnectLikeError(err)) {
logError("Failed to load gateway models.", err);
}
}
};
void loadModels();
return () => {
cancelled = true;
};
}, [
params.client,
params.isDisconnectLikeError,
params.setGatewayConfigSnapshot,
params.setGatewayModels,
params.setGatewayModelsError,
params.status,
logError,
]);
return {
refreshGatewayConfigSnapshot,
};
}
@@ -0,0 +1,273 @@
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 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 = params.agents;
}, [params.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([
params.client.call<SummaryStatusSnapshot>("status", {}),
params.client.call<SummaryPreviewSnapshot>("sessions.preview", {
keys: summaryIntent.keys,
limit: summaryIntent.limit,
maxChars: summaryIntent.maxChars,
}),
]);
for (const entry of buildSummarySnapshotPatches({
agents: activeAgents,
statusSummary,
previewResult,
})) {
params.dispatch({
type: "updateAgent",
agentId: entry.agentId,
patch: entry.patch,
});
}
} catch (error) {
if (!params.isDisconnectLikeError(error)) {
console.error("Failed to load summary snapshot.", error);
}
}
}, [params.client, params.dispatch, params.isDisconnectLikeError]);
const loadAgentHistory = useCallback(
async (agentId: string, options?: { limit?: number }) => {
const commands = await runHistorySyncOperation({
client: params.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: params.dispatch,
logMetric: (metric, meta) => logTranscriptDebugMetric(metric, meta),
isDisconnectLikeError: params.isDisconnectLikeError,
logError: (message, error) => console.error(message, error),
});
},
[
defaultHistoryLimit,
maxHistoryLimit,
params.client,
params.dispatch,
params.isDisconnectLikeError,
]
);
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 (params.status !== "connected") return;
const commands = await runAgentReconcileOperation({
client: params.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: params.isDisconnectLikeError,
});
executeAgentReconcileCommands({
commands,
dispatch: params.dispatch,
clearRunTracking: params.clearRunTracking,
requestHistoryRefresh: (agentId) => {
void loadAgentHistory(agentId);
},
logInfo: (message) => console.info(message),
logWarn: (message, error) => console.warn(message, error),
});
}, [
loadAgentHistory,
params.clearRunTracking,
params.client,
params.dispatch,
params.isDisconnectLikeError,
params.status,
]);
useEffect(() => {
if (params.status !== "connected") return;
void loadSummarySnapshot();
}, [loadSummarySnapshot, params.status]);
useEffect(() => {
const reconcileIntent = resolveRuntimeSyncReconcilePollingIntent({
status: params.status,
});
if (reconcileIntent.kind === "stop") return;
void reconcileRunningAgents();
const timer = window.setInterval(() => {
void reconcileRunningAgents();
}, reconcileIntent.intervalMs);
return () => {
window.clearInterval(timer);
};
}, [params.status, reconcileRunningAgents]);
useEffect(() => {
const bootstrapAgentIds = resolveRuntimeSyncBootstrapHistoryAgentIds({
status: params.status,
agents: params.agents,
});
for (const agentId of bootstrapAgentIds) {
void loadAgentHistory(agentId);
}
}, [loadAgentHistory, params.agents, params.status]);
useEffect(() => {
const pollingIntent = resolveRuntimeSyncFocusedHistoryPollingIntent({
status: params.status,
focusedAgentId: params.focusedAgentId,
focusedAgentRunning: params.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);
};
}, [loadAgentHistory, params.focusedAgentId, params.focusedAgentRunning, params.status]);
useEffect(() => {
return params.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();
}
});
}, [loadSummarySnapshot, params.client, reconcileRunningAgents]);
return {
loadSummarySnapshot,
loadAgentHistory,
loadMoreAgentHistory,
reconcileRunningAgents,
clearHistoryInFlight,
};
}
@@ -0,0 +1,256 @@
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 applyCommands = useCallback(
(commands: SettingsRouteNavCommand[]) => {
executeSettingsRouteCommands(commands, {
dispatchSelectAgent: params.dispatchSelectAgent,
setInspectSidebar: params.setInspectSidebar,
setMobilePaneChat: params.setMobilePaneChat,
setPersonalityHasUnsavedChanges: params.setPersonalityHasUnsavedChanges,
flushPendingDraft: params.flushPendingDraft,
push: params.push,
replace: params.replace,
});
},
[
params.dispatchSelectAgent,
params.flushPendingDraft,
params.push,
params.replace,
params.setInspectSidebar,
params.setMobilePaneChat,
params.setPersonalityHasUnsavedChanges,
]
);
const handleBackToChat = useCallback(() => {
const needsDiscardConfirmation = shouldConfirmDiscardPersonalityChanges({
settingsRouteActive: params.settingsRouteActive,
activeTab: params.activeTab,
personalityHasUnsavedChanges: params.personalityHasUnsavedChanges,
});
const discardConfirmed = needsDiscardConfirmation ? params.confirmDiscard() : true;
const commands = planBackToChatCommands({
settingsRouteActive: params.settingsRouteActive,
activeTab: params.activeTab,
personalityHasUnsavedChanges: params.personalityHasUnsavedChanges,
discardConfirmed,
});
applyCommands(commands);
}, [
applyCommands,
params.activeTab,
params.confirmDiscard,
params.personalityHasUnsavedChanges,
params.settingsRouteActive,
]);
const handleSettingsRouteTabChange = useCallback(
(nextTab: SettingsRouteTab) => {
const currentTab = params.inspectSidebar?.tab ?? "personality";
const needsDiscardConfirmation =
currentTab === "personality" &&
nextTab !== "personality" &&
shouldConfirmDiscardPersonalityChanges({
settingsRouteActive: params.settingsRouteActive,
activeTab: currentTab,
personalityHasUnsavedChanges: params.personalityHasUnsavedChanges,
});
const discardConfirmed = needsDiscardConfirmation ? params.confirmDiscard() : true;
const commands = planSettingsTabChangeCommands({
nextTab,
currentInspectSidebar: params.inspectSidebar,
settingsRouteAgentId: params.settingsRouteAgentId,
settingsRouteActive: params.settingsRouteActive,
personalityHasUnsavedChanges: params.personalityHasUnsavedChanges,
discardConfirmed,
});
applyCommands(commands);
},
[
applyCommands,
params.confirmDiscard,
params.inspectSidebar,
params.personalityHasUnsavedChanges,
params.settingsRouteActive,
params.settingsRouteAgentId,
]
);
const handleOpenAgentSettingsRoute = useCallback(
(agentId: string) => {
const commands = planOpenSettingsRouteCommands({
agentId,
currentInspectSidebar: params.inspectSidebar,
focusedAgentId: params.focusedAgentId,
});
applyCommands(commands);
},
[applyCommands, params.focusedAgentId, params.inspectSidebar]
);
const handleFleetSelectAgent = useCallback(
(agentId: string) => {
const commands = planFleetSelectCommands({
agentId,
currentInspectSidebar: params.inspectSidebar,
focusedAgentId: params.focusedAgentId,
});
applyCommands(commands);
},
[applyCommands, params.focusedAgentId, params.inspectSidebar]
);
useEffect(() => {
const routeAgentId = (params.settingsRouteAgentId ?? "").trim();
const hasRouteAgent = routeAgentId
? params.agents.some((agent) => agent.agentId === routeAgentId)
: false;
const commands = planSettingsRouteSyncCommands({
settingsRouteActive: params.settingsRouteActive,
settingsRouteAgentId: params.settingsRouteAgentId,
status: params.status,
agentsLoadedOnce: params.agentsLoadedOnce,
selectedAgentId: params.selectedAgentId,
hasRouteAgent,
currentInspectSidebar: params.inspectSidebar,
});
applyCommands(commands);
}, [
applyCommands,
params.agents,
params.agentsLoadedOnce,
params.inspectSidebar,
params.selectedAgentId,
params.settingsRouteActive,
params.settingsRouteAgentId,
params.status,
]);
useEffect(() => {
const hasSelectedAgentInAgents = params.selectedAgentId
? params.agents.some((agent) => agent.agentId === params.selectedAgentId)
: false;
const hasInspectSidebarAgent = params.inspectSidebar?.agentId
? params.agents.some((agent) => agent.agentId === params.inspectSidebar?.agentId)
: false;
const commands = planNonRouteSelectionSyncCommands({
settingsRouteActive: params.settingsRouteActive,
selectedAgentId: params.selectedAgentId,
focusedAgentId: params.focusedAgentId,
hasSelectedAgentInAgents,
currentInspectSidebar: params.inspectSidebar,
hasInspectSidebarAgent,
});
applyCommands(commands);
}, [
applyCommands,
params.agents,
params.focusedAgentId,
params.inspectSidebar,
params.selectedAgentId,
params.settingsRouteActive,
]);
return {
handleBackToChat,
handleSettingsRouteTabChange,
handleOpenAgentSettingsRoute,
handleFleetSelectAgent,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,369 @@
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";
}
| {
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 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);
if (
cleaned &&
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 };
}
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,846 @@
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";
export type RuntimeEventCoordinatorState = {
runtimeTerminalState: RuntimeTerminalState;
chatRunSeen: Set<string>;
assistantStreamByRun: Map<string, string>;
thinkingStreamByRun: Map<string, string>;
thinkingStartedAtByRun: Map<string, number>;
toolLinesSeenByRun: Map<string, Set<string>>;
historyRefreshRequestedByRun: Set<string>;
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";
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 {
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,
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,
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,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,96 @@
import { describe, expect, it } from "vitest";
import {
planAgentSettingsMutation,
type AgentSettingsMutationContext,
} from "@/features/agents/operations/agentSettingsMutationWorkflow";
const createContext = (
overrides?: Partial<AgentSettingsMutationContext>
): AgentSettingsMutationContext => ({
status: "connected",
hasCreateBlock: false,
hasRenameBlock: false,
hasDeleteBlock: false,
cronCreateBusy: false,
cronRunBusyJobId: null,
cronDeleteBusyJobId: null,
...(overrides ?? {}),
});
describe("agentSettingsMutationWorkflow", () => {
it("denies_guarded_actions_when_not_connected", () => {
const result = planAgentSettingsMutation(
{ kind: "rename-agent", agentId: "agent-1" },
createContext({ status: "disconnected" })
);
expect(result).toEqual({
kind: "deny",
reason: "start-guard-deny",
message: null,
guardReason: "not-connected",
});
});
it("denies_delete_for_reserved_main_agent_with_actionable_message", () => {
const result = planAgentSettingsMutation(
{ kind: "delete-agent", agentId: " main " },
createContext()
);
expect(result).toEqual({
kind: "deny",
reason: "reserved-main-delete",
message: "The main agent cannot be deleted.",
});
});
it("denies_guarded_actions_when_mutation_block_is_active", () => {
const result = planAgentSettingsMutation(
{ kind: "update-agent-permissions", agentId: "agent-1" },
createContext({ hasCreateBlock: true })
);
expect(result).toEqual({
kind: "deny",
reason: "start-guard-deny",
message: null,
guardReason: "create-block-active",
});
});
it("denies_cron_run_delete_when_other_cron_action_is_busy", () => {
const result = planAgentSettingsMutation(
{ kind: "run-cron-job", agentId: "agent-1", jobId: "job-1" },
createContext({ cronDeleteBusyJobId: "job-2" })
);
expect(result).toEqual({
kind: "deny",
reason: "cron-action-busy",
message: null,
});
});
it("allows_with_normalized_agent_and_job_ids", () => {
const runResult = planAgentSettingsMutation(
{ kind: "run-cron-job", agentId: " agent-1 ", jobId: " job-1 " },
createContext()
);
const deleteResult = planAgentSettingsMutation(
{ kind: "delete-agent", agentId: " agent-2 " },
createContext()
);
expect(runResult).toEqual({
kind: "allow",
normalizedAgentId: "agent-1",
normalizedJobId: "job-1",
});
expect(deleteResult).toEqual({
kind: "allow",
normalizedAgentId: "agent-2",
});
});
});
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import {
planDraftFlushIntent,
planDraftTimerIntent,
planNewSessionIntent,
planStopRunIntent,
} from "@/features/agents/operations/chatInteractionWorkflow";
describe("chatInteractionWorkflow", () => {
it("denies stop-run when gateway is disconnected", () => {
const intent = planStopRunIntent({
status: "disconnected",
agentId: "agent-1",
sessionKey: "session-1",
busyAgentId: null,
});
expect(intent).toEqual({
kind: "deny",
reason: "not-connected",
message: "Connect to gateway before stopping a run.",
});
});
it("denies stop-run when session key is missing", () => {
const intent = planStopRunIntent({
status: "connected",
agentId: "agent-1",
sessionKey: " ",
busyAgentId: null,
});
expect(intent).toEqual({
kind: "deny",
reason: "missing-session-key",
message: "Missing session key for agent.",
});
});
it("skips duplicate stop-run requests while same agent is busy", () => {
const intent = planStopRunIntent({
status: "connected",
agentId: "agent-1",
sessionKey: "session-1",
busyAgentId: "agent-1",
});
expect(intent).toEqual({
kind: "skip-busy",
});
});
it("allows stop-run with a connected gateway and normalized session key", () => {
const intent = planStopRunIntent({
status: "connected",
agentId: "agent-1",
sessionKey: " session-1 ",
busyAgentId: "agent-2",
});
expect(intent).toEqual({
kind: "allow",
sessionKey: "session-1",
});
});
it("denies new-session when the agent cannot be found", () => {
const intent = planNewSessionIntent({
hasAgent: false,
sessionKey: "session-1",
});
expect(intent).toEqual({
kind: "deny",
reason: "missing-agent",
message: "Failed to start new session: agent not found.",
});
});
it("denies new-session when session key is missing", () => {
const intent = planNewSessionIntent({
hasAgent: true,
sessionKey: "",
});
expect(intent).toEqual({
kind: "deny",
reason: "missing-session-key",
message: "Missing session key for agent.",
});
});
it("allows new-session when agent exists and session key is present", () => {
const intent = planNewSessionIntent({
hasAgent: true,
sessionKey: " session-1 ",
});
expect(intent).toEqual({
kind: "allow",
sessionKey: "session-1",
});
});
it("skips draft flush when agent id is missing", () => {
const intent = planDraftFlushIntent({
agentId: null,
hasPendingValue: true,
});
expect(intent).toEqual({
kind: "skip",
reason: "missing-agent-id",
});
});
it("skips draft flush when there is no pending draft value", () => {
const intent = planDraftFlushIntent({
agentId: "agent-1",
hasPendingValue: false,
});
expect(intent).toEqual({
kind: "skip",
reason: "missing-pending-value",
});
});
it("flushes draft when an agent id and pending value are present", () => {
const intent = planDraftFlushIntent({
agentId: "agent-1",
hasPendingValue: true,
});
expect(intent).toEqual({
kind: "flush",
agentId: "agent-1",
});
});
it("schedules draft timer with default debounce", () => {
const intent = planDraftTimerIntent({
agentId: "agent-1",
});
expect(intent).toEqual({
kind: "schedule",
agentId: "agent-1",
delayMs: 250,
});
});
it("allows overriding draft timer delay", () => {
const intent = planDraftTimerIntent({
agentId: "agent-1",
delayMs: 500,
});
expect(intent).toEqual({
kind: "schedule",
agentId: "agent-1",
delayMs: 500,
});
});
it("skips draft timer scheduling when agent id is missing", () => {
const intent = planDraftTimerIntent({
agentId: "",
delayMs: 250,
});
expect(intent).toEqual({
kind: "skip",
reason: "missing-agent-id",
});
});
});
@@ -0,0 +1,130 @@
import { describe, expect, it, vi } from "vitest";
import { runCreateAgentBootstrapOperation } from "@/features/agents/operations/createAgentBootstrapOperation";
describe("createAgentBootstrapOperation", () => {
it("retries load and lookup once before unresolved-created-agent disposition", async () => {
const loadAgents = vi.fn(async () => undefined);
const findAgentById = vi.fn(() => null);
const applyDefaultPermissions = vi.fn(async () => undefined);
const refreshGatewayConfigSnapshot = vi.fn(async () => undefined);
const commands = await runCreateAgentBootstrapOperation({
completion: { agentId: "agent-1", agentName: "Agent One" },
focusedAgentId: "focused-1",
loadAgents,
findAgentById,
applyDefaultPermissions,
refreshGatewayConfigSnapshot,
});
expect(loadAgents).toHaveBeenCalledTimes(2);
expect(findAgentById).toHaveBeenCalledTimes(2);
expect(findAgentById).toHaveBeenNthCalledWith(1, "agent-1");
expect(findAgentById).toHaveBeenNthCalledWith(2, "agent-1");
expect(applyDefaultPermissions).not.toHaveBeenCalled();
expect(refreshGatewayConfigSnapshot).not.toHaveBeenCalled();
expect(commands).toEqual([
{
kind: "set-create-modal-error",
message: 'Agent "Agent One" was created, but Studio could not load it yet.',
},
{
kind: "set-global-error",
message: 'Agent "Agent One" was created, but Studio could not load it yet.',
},
{ kind: "set-create-block", value: null },
{ kind: "set-create-modal-open", open: false },
]);
});
it("runs bootstrap success flow and refreshes gateway config snapshot", async () => {
const loadAgents = vi.fn(async () => undefined);
const findAgentById = vi.fn(() => ({ agentId: "agent-1", sessionKey: "session-1" }));
const applyDefaultPermissions = vi.fn(async () => undefined);
const refreshGatewayConfigSnapshot = vi.fn(async () => undefined);
const commands = await runCreateAgentBootstrapOperation({
completion: { agentId: "agent-1", agentName: "Agent One" },
focusedAgentId: "focused-1",
loadAgents,
findAgentById,
applyDefaultPermissions,
refreshGatewayConfigSnapshot,
});
const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft");
const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent");
expect(loadAgents).toHaveBeenCalledTimes(1);
expect(findAgentById).toHaveBeenCalledTimes(1);
expect(applyDefaultPermissions).toHaveBeenCalledWith({
agentId: "agent-1",
sessionKey: "session-1",
});
expect(refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1);
expect(flushIndex).toBeGreaterThanOrEqual(0);
expect(selectIndex).toBeGreaterThan(flushIndex);
expect(commands.find((entry) => entry.kind === "set-global-error")).toBeUndefined();
expect(commands).toContainEqual({ kind: "set-create-modal-error", message: null });
});
it("keeps create success disposition when bootstrap fails and skips snapshot refresh", async () => {
const loadAgents = vi.fn(async () => undefined);
const findAgentById = vi.fn(() => ({ agentId: "agent-1", sessionKey: "session-1" }));
const applyDefaultPermissions = vi.fn(async () => {
throw new Error("permissions exploded");
});
const refreshGatewayConfigSnapshot = vi.fn(async () => undefined);
const commands = await runCreateAgentBootstrapOperation({
completion: { agentId: "agent-1", agentName: "Agent One" },
focusedAgentId: "focused-1",
loadAgents,
findAgentById,
applyDefaultPermissions,
refreshGatewayConfigSnapshot,
});
const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft");
const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent");
expect(loadAgents).toHaveBeenCalledTimes(1);
expect(findAgentById).toHaveBeenCalledTimes(1);
expect(applyDefaultPermissions).toHaveBeenCalledTimes(1);
expect(refreshGatewayConfigSnapshot).not.toHaveBeenCalled();
expect(flushIndex).toBeGreaterThanOrEqual(0);
expect(selectIndex).toBeGreaterThan(flushIndex);
expect(commands).toContainEqual({
kind: "set-global-error",
message: "Agent created, but default permissions could not be applied: permissions exploded",
});
expect(commands).toContainEqual({
kind: "set-create-modal-error",
message: "Default permissions failed: permissions exploded",
});
expect(commands).toContainEqual({ kind: "select-agent", agentId: "agent-1" });
});
it("uses fallback bootstrap error message for non-Error throws", async () => {
const commands = await runCreateAgentBootstrapOperation({
completion: { agentId: "agent-1", agentName: "Agent One" },
focusedAgentId: "focused-1",
loadAgents: async () => undefined,
findAgentById: () => ({ agentId: "agent-1", sessionKey: "session-1" }),
applyDefaultPermissions: async () => {
throw "boom";
},
refreshGatewayConfigSnapshot: async () => undefined,
});
expect(commands).toContainEqual({
kind: "set-global-error",
message: "Agent created, but default permissions could not be applied: Failed to apply default permissions.",
});
expect(commands).toContainEqual({
kind: "set-create-modal-error",
message: "Default permissions failed: Failed to apply default permissions.",
});
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { planCreateAgentBootstrapCommands } from "@/features/agents/operations/createAgentBootstrapWorkflow";
describe("createAgentBootstrapWorkflow", () => {
it("plans unresolved-created-agent failure disposition", () => {
const commands = planCreateAgentBootstrapCommands({
completion: { agentId: "agent-1", agentName: "Agent One" },
createdAgent: null,
bootstrapErrorMessage: null,
focusedAgentId: "focused-1",
});
expect(commands).toEqual([
{
kind: "set-create-modal-error",
message: 'Agent "Agent One" was created, but Studio could not load it yet.',
},
{
kind: "set-global-error",
message: 'Agent "Agent One" was created, but Studio could not load it yet.',
},
{ kind: "set-create-block", value: null },
{ kind: "set-create-modal-open", open: false },
]);
});
it("plans bootstrap success disposition with draft flush before selection", () => {
const commands = planCreateAgentBootstrapCommands({
completion: { agentId: "agent-1", agentName: "Agent One" },
createdAgent: { agentId: "agent-1", sessionKey: "session-1" },
bootstrapErrorMessage: null,
focusedAgentId: "focused-1",
});
const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft");
const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent");
expect(flushIndex).toBeGreaterThanOrEqual(0);
expect(selectIndex).toBeGreaterThan(flushIndex);
expect(commands).toContainEqual({ kind: "set-create-modal-error", message: null });
expect(commands).toContainEqual({ kind: "flush-pending-draft", agentId: "focused-1" });
expect(commands).toContainEqual({ kind: "select-agent", agentId: "agent-1" });
expect(commands).toContainEqual({
kind: "set-inspect-sidebar",
agentId: "agent-1",
tab: "capabilities",
});
expect(commands).toContainEqual({ kind: "set-mobile-pane", pane: "chat" });
expect(commands).toContainEqual({ kind: "set-create-block", value: null });
expect(commands).toContainEqual({ kind: "set-create-modal-open", open: false });
expect(commands.find((entry) => entry.kind === "set-global-error")).toBeUndefined();
});
it("plans bootstrap failure disposition without blocking selection flow", () => {
const commands = planCreateAgentBootstrapCommands({
completion: { agentId: "agent-1", agentName: "Agent One" },
createdAgent: { agentId: "agent-1", sessionKey: "session-1" },
bootstrapErrorMessage: "permissions exploded",
focusedAgentId: "focused-1",
});
const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft");
const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent");
expect(flushIndex).toBeGreaterThanOrEqual(0);
expect(selectIndex).toBeGreaterThan(flushIndex);
expect(commands).toContainEqual({
kind: "set-global-error",
message: "Agent created, but default permissions could not be applied: permissions exploded",
});
expect(commands).toContainEqual({
kind: "set-create-modal-error",
message: "Default permissions failed: permissions exploded",
});
expect(commands).toContainEqual({ kind: "select-agent", agentId: "agent-1" });
expect(commands).toContainEqual({
kind: "set-inspect-sidebar",
agentId: "agent-1",
tab: "capabilities",
});
expect(commands).toContainEqual({ kind: "set-mobile-pane", pane: "chat" });
expect(commands).toContainEqual({ kind: "set-create-block", value: null });
expect(commands).toContainEqual({ kind: "set-create-modal-open", open: false });
});
});
@@ -0,0 +1,319 @@
import { describe, expect, it } from "vitest";
import type { PendingExecApproval } from "@/features/agents/approvals/types";
import {
planAutoResumeIntent,
planAwaitingUserInputPatches,
planIngressCommands,
planPausedRunMapCleanup,
planPauseRunIntent,
planPendingPruneDelay,
planPrunedPendingState,
} from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
import type { ApprovalPendingState } from "@/features/agents/approvals/execApprovalRuntimeCoordinator";
import type { AgentState } from "@/features/agents/state/store";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:main",
status: "running",
sessionCreated: true,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: "run-1",
runStartedAt: 1,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: true,
model: "openai/gpt-5",
thinkingLevel: "medium",
avatarSeed: "seed-1",
avatarUrl: null,
sessionExecAsk: "always",
...overrides,
});
const createApproval = (
id: string,
overrides?: Partial<PendingExecApproval>
): PendingExecApproval => ({
id,
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
command: "npm run test",
cwd: "/repo",
host: "gateway",
security: "allowlist",
ask: "always",
resolvedPath: "/usr/bin/npm",
createdAtMs: 1,
expiresAtMs: 10_000,
resolving: false,
error: null,
...overrides,
});
const createPendingState = (
overrides?: Partial<ApprovalPendingState>
): ApprovalPendingState => ({
approvalsByAgentId: {},
unscopedApprovals: [],
...overrides,
});
describe("execApprovalControlLoopWorkflow", () => {
it("plans stale paused-run cleanup from paused map", () => {
const stale = planPausedRunMapCleanup({
pausedRunIdByAgentId: new Map([
["agent-1", "run-1"],
["agent-2", "run-old"],
["missing-agent", "run-x"],
]),
agents: [
createAgent(),
createAgent({
agentId: "agent-2",
sessionKey: "agent:agent-2:main",
runId: "run-2",
}),
],
});
expect(stale).toEqual(["agent-2", "missing-agent"]);
});
it("plans pause intent for a running agent that needs exec approval", () => {
const intent = planPauseRunIntent({
approval: createApproval("approval-1"),
preferredAgentId: "agent-1",
agents: [createAgent()],
pausedRunIdByAgentId: new Map(),
});
expect(intent).toEqual({
kind: "pause",
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
runId: "run-1",
});
});
it("skips pause intent when the run is already paused", () => {
const intent = planPauseRunIntent({
approval: createApproval("approval-1"),
preferredAgentId: "agent-1",
agents: [createAgent()],
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
});
expect(intent).toEqual({ kind: "skip", reason: "pause-policy-denied" });
});
it("plans ingress commands for approval requested events", () => {
const event: EventFrame = {
type: "event",
event: "exec.approval.requested",
payload: {
id: "approval-1",
request: {
command: "npm run test",
cwd: "/repo",
host: "gateway",
security: "allowlist",
ask: "always",
agentId: "agent-1",
resolvedPath: "/usr/bin/npm",
sessionKey: "agent:agent-1:main",
},
createdAtMs: 100,
expiresAtMs: 200,
},
};
const commands = planIngressCommands({
event,
agents: [createAgent()],
pendingState: createPendingState(),
pausedRunIdByAgentId: new Map(),
seenCronDedupeKeys: new Set(),
nowMs: 150,
});
expect(commands[0]).toMatchObject({ kind: "replacePendingState" });
expect(commands).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "pauseRunForApproval",
preferredAgentId: "agent-1",
}),
{ kind: "markActivity", agentId: "agent-1" },
])
);
});
it("plans cron ingress commands with dedupe and transcript append", () => {
const event: EventFrame = {
type: "event",
event: "cron",
payload: {
action: "finished",
sessionKey: "agent:agent-1:main",
jobId: "job-1",
sessionId: "session-1",
runAtMs: 123,
status: "ok",
summary: "cron summary",
},
};
const commands = planIngressCommands({
event,
agents: [createAgent()],
pendingState: createPendingState(),
pausedRunIdByAgentId: new Map(),
seenCronDedupeKeys: new Set(),
nowMs: 1000,
});
expect(commands).toEqual([
{ kind: "recordCronDedupeKey", dedupeKey: "cron:job-1:session-1" },
{
kind: "appendCronTranscript",
intent: {
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
dedupeKey: "cron:job-1:session-1",
line: "Cron finished (ok): job-1\n\ncron summary",
timestampMs: 123,
activityAtMs: 123,
},
},
]);
});
it("plans prune delay, pruned state, and awaiting-user-input patches", () => {
const pendingState = createPendingState({
approvalsByAgentId: {
"agent-1": [createApproval("a-1", { expiresAtMs: 6_000 })],
},
unscopedApprovals: [
createApproval("a-2", {
agentId: null,
sessionKey: "agent:agent-2:main",
expiresAtMs: 7_000,
}),
],
});
const delay = planPendingPruneDelay({
pendingState,
nowMs: 5_000,
graceMs: 500,
});
expect(delay).toBe(1_500);
const pruned = planPrunedPendingState({
pendingState: {
approvalsByAgentId: {
"agent-1": [
createApproval("expired", { expiresAtMs: 4_000 }),
createApproval("active", { expiresAtMs: 6_000 }),
],
},
unscopedApprovals: [
createApproval("active-unscoped", {
agentId: null,
sessionKey: "agent:agent-2:main",
expiresAtMs: 7_000,
}),
createApproval("expired-unscoped", {
agentId: null,
sessionKey: "agent:agent-2:main",
expiresAtMs: 4_100,
}),
],
},
nowMs: 5_000,
graceMs: 500,
});
expect(pruned.approvalsByAgentId).toEqual({
"agent-1": [createApproval("active", { expiresAtMs: 6_000 })],
});
expect(pruned.unscopedApprovals).toEqual([
createApproval("active-unscoped", {
agentId: null,
sessionKey: "agent:agent-2:main",
expiresAtMs: 7_000,
}),
]);
const patches = planAwaitingUserInputPatches({
agents: [
createAgent({ agentId: "agent-1", awaitingUserInput: false }),
createAgent({
agentId: "agent-2",
sessionKey: "agent:agent-2:main",
runId: "run-2",
awaitingUserInput: true,
}),
],
approvalsByAgentId: {
"agent-1": [createApproval("a-1")],
},
});
expect(patches).toEqual([
{ agentId: "agent-1", awaitingUserInput: true },
{ agentId: "agent-2", awaitingUserInput: false },
]);
});
it("plans auto-resume intent only when preflight and dispatch both pass", () => {
const skip = planAutoResumeIntent({
approval: createApproval("approval-1"),
targetAgentId: "agent-1",
pendingState: createPendingState({
approvalsByAgentId: {
"agent-1": [createApproval("approval-1"), createApproval("sibling")],
},
}),
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
agents: [createAgent()],
});
expect(skip).toEqual({ kind: "skip", reason: "blocking-pending-approvals" });
const resume = planAutoResumeIntent({
approval: createApproval("approval-1"),
targetAgentId: "agent-1",
pendingState: createPendingState(),
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
agents: [createAgent({ status: "running", runId: "run-1" })],
});
expect(resume).toEqual({
kind: "resume",
targetAgentId: "agent-1",
pausedRunId: "run-1",
sessionKey: "agent:agent-1:main",
});
});
});
@@ -0,0 +1,297 @@
import { describe, expect, it, vi } from "vitest";
import type { PendingExecApproval } from "@/features/agents/approvals/types";
import {
EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
runExecApprovalAutoResumeOperation,
runGatewayEventIngressOperation,
runPauseRunForExecApprovalOperation,
runResolveExecApprovalOperation,
} from "@/features/agents/approvals/execApprovalRunControlOperation";
import type { ExecApprovalPendingSnapshot } from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
import type { AgentState } from "@/features/agents/state/store";
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:main",
status: "running",
sessionCreated: true,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: "run-1",
runStartedAt: 1,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: true,
model: "openai/gpt-5",
thinkingLevel: "medium",
avatarSeed: "seed-1",
avatarUrl: null,
sessionExecAsk: "always",
...overrides,
});
const createApproval = (id: string, overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({
id,
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
command: "npm run test",
cwd: "/repo",
host: "gateway",
security: "allowlist",
ask: "always",
resolvedPath: "/usr/bin/npm",
createdAtMs: 1,
expiresAtMs: 10_000,
resolving: false,
error: null,
...overrides,
});
const createPendingState = (
overrides?: Partial<ExecApprovalPendingSnapshot>
): ExecApprovalPendingSnapshot => ({
approvalsByAgentId: {},
unscopedApprovals: [],
...overrides,
});
describe("execApprovalRunControlOperation", () => {
it("pauses a run for pending approval after stale paused-run cleanup", async () => {
const call = vi.fn(async () => ({ ok: true }));
const pausedRunIdByAgentId = new Map<string, string>([
["stale-agent", "stale-run"],
]);
await runPauseRunForExecApprovalOperation({
status: "connected",
client: { call },
approval: createApproval("approval-1"),
preferredAgentId: "agent-1",
getAgents: () => [createAgent({ runId: "run-1" })],
pausedRunIdByAgentId,
isDisconnectLikeError: () => false,
logWarn: vi.fn(),
});
expect(pausedRunIdByAgentId.has("stale-agent")).toBe(false);
expect(pausedRunIdByAgentId.get("agent-1")).toBe("run-1");
expect(call).toHaveBeenCalledWith("chat.abort", {
sessionKey: "agent:agent-1:main",
});
});
it("reverts paused-run map entry when pause abort call fails", async () => {
const call = vi.fn(async () => {
throw new Error("abort failed");
});
const logWarn = vi.fn();
const pausedRunIdByAgentId = new Map<string, string>();
await runPauseRunForExecApprovalOperation({
status: "connected",
client: { call },
approval: createApproval("approval-1"),
preferredAgentId: "agent-1",
getAgents: () => [createAgent({ runId: "run-1" })],
pausedRunIdByAgentId,
isDisconnectLikeError: () => false,
logWarn,
});
expect(pausedRunIdByAgentId.has("agent-1")).toBe(false);
expect(logWarn).toHaveBeenCalledWith(
"Failed to pause run for pending exec approval.",
expect.any(Error)
);
});
it("auto-resumes in order: dispatch running, wait paused run, then send follow-up", async () => {
const call = vi.fn(async (method: string) => {
if (method === "agent.wait") return { status: "ok" };
throw new Error(`Unexpected method ${method}`);
});
const dispatch = vi.fn();
const sendChatMessage = vi.fn(async () => undefined);
const pausedRunIdByAgentId = new Map<string, string>([["agent-1", "run-1"]]);
await runExecApprovalAutoResumeOperation({
client: { call },
dispatch,
approval: createApproval("approval-1"),
targetAgentId: "agent-1",
getAgents: () => [createAgent({ status: "running", runId: "run-1" })],
getPendingState: () => createPendingState(),
pausedRunIdByAgentId,
isDisconnectLikeError: () => false,
logWarn: vi.fn(),
sendChatMessage,
now: () => 777,
});
expect(pausedRunIdByAgentId.has("agent-1")).toBe(false);
expect(dispatch).toHaveBeenCalledWith({
type: "updateAgent",
agentId: "agent-1",
patch: { status: "running", runId: "run-1", lastActivityAt: 777 },
});
expect(call).toHaveBeenCalledWith("agent.wait", {
runId: "run-1",
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
});
expect(sendChatMessage).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
echoUserMessage: false,
message: `${EXEC_APPROVAL_AUTO_RESUME_MARKER}\nContinue where you left off and finish the task.`,
})
);
expect(dispatch.mock.invocationCallOrder[0]).toBeLessThan(call.mock.invocationCallOrder[0]);
expect(call.mock.invocationCallOrder[0]).toBeLessThan(
sendChatMessage.mock.invocationCallOrder[0]
);
});
it("skips follow-up send when post-wait auto-resume intent no longer holds", async () => {
const call = vi.fn(async () => ({ status: "ok" }));
const dispatch = vi.fn();
const sendChatMessage = vi.fn(async () => undefined);
const pausedRunIdByAgentId = new Map<string, string>([["agent-1", "run-1"]]);
let readCount = 0;
const getAgents = () => {
readCount += 1;
if (readCount === 1) {
return [createAgent({ status: "running", runId: "run-1" })];
}
return [createAgent({ status: "running", runId: "run-2" })];
};
await runExecApprovalAutoResumeOperation({
client: { call },
dispatch,
approval: createApproval("approval-1"),
targetAgentId: "agent-1",
getAgents,
getPendingState: () => createPendingState(),
pausedRunIdByAgentId,
isDisconnectLikeError: () => false,
logWarn: vi.fn(),
sendChatMessage,
});
expect(call).toHaveBeenCalledWith("agent.wait", {
runId: "run-1",
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
});
expect(sendChatMessage).not.toHaveBeenCalled();
});
it("resolves approvals through resolver and delegates allow flow to auto-resume operation", async () => {
const resolveExecApproval = vi.fn(async (params: { onAllowed?: (input: {
approval: PendingExecApproval;
targetAgentId: string;
}) => Promise<void> }) => {
await params.onAllowed?.({
approval: createApproval("approval-1"),
targetAgentId: "agent-1",
});
});
const runAutoResume = vi.fn(async () => undefined);
await runResolveExecApprovalOperation({
client: { call: vi.fn(async () => ({ ok: true })) },
approvalId: "approval-1",
decision: "allow-once",
getAgents: () => [createAgent()],
getPendingState: () => createPendingState(),
setPendingExecApprovalsByAgentId: vi.fn(),
setUnscopedPendingExecApprovals: vi.fn(),
requestHistoryRefresh: vi.fn(),
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
dispatch: vi.fn(),
isDisconnectLikeError: () => false,
resolveExecApproval: resolveExecApproval as never,
runAutoResume,
});
expect(resolveExecApproval).toHaveBeenCalledTimes(1);
expect(runAutoResume).toHaveBeenCalledWith(
expect.objectContaining({
approval: expect.objectContaining({ id: "approval-1" }),
targetAgentId: "agent-1",
})
);
});
it("executes ingress commands from gateway events", () => {
const dispatch = vi.fn();
const replacePendingState = vi.fn();
const pauseRunForApproval = vi.fn(async () => undefined);
const recordCronDedupeKey = vi.fn();
const event: EventFrame = {
type: "event",
event: "cron",
payload: {
action: "finished",
sessionKey: "agent:agent-1:main",
jobId: "job-1",
sessionId: "session-1",
runAtMs: 123,
status: "ok",
summary: "cron summary",
},
};
const commands = runGatewayEventIngressOperation({
event,
getAgents: () => [createAgent()],
getPendingState: () => createPendingState(),
pausedRunIdByAgentId: new Map(),
seenCronDedupeKeys: new Set(),
nowMs: 1_000,
replacePendingState,
pauseRunForApproval,
dispatch,
recordCronDedupeKey,
});
expect(commands).toHaveLength(2);
expect(recordCronDedupeKey).toHaveBeenCalledWith("cron:job-1:session-1");
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: "appendOutput",
agentId: "agent-1",
})
);
expect(dispatch).toHaveBeenCalledWith({
type: "markActivity",
agentId: "agent-1",
at: 123,
});
expect(replacePendingState).not.toHaveBeenCalled();
expect(pauseRunForApproval).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import type { PendingExecApproval } from "@/features/agents/approvals/types";
import {
planApprovalIngressRunControl,
planAutoResumeRunControl,
planPauseRunControl,
} from "@/features/agents/approvals/execApprovalRunControlWorkflow";
import type { ExecApprovalPendingSnapshot } from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
import type { AgentState } from "@/features/agents/state/store";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:main",
status: "running",
sessionCreated: true,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: "run-1",
runStartedAt: 1,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: true,
model: "openai/gpt-5",
thinkingLevel: "medium",
avatarSeed: "seed-1",
avatarUrl: null,
sessionExecAsk: "always",
...overrides,
});
const createApproval = (id: string, overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({
id,
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
command: "npm run test",
cwd: "/repo",
host: "gateway",
security: "allowlist",
ask: "always",
resolvedPath: "/usr/bin/npm",
createdAtMs: 1,
expiresAtMs: 10_000,
resolving: false,
error: null,
...overrides,
});
const createPendingState = (
overrides?: Partial<ExecApprovalPendingSnapshot>
): ExecApprovalPendingSnapshot => ({
approvalsByAgentId: {},
unscopedApprovals: [],
...overrides,
});
describe("execApprovalRunControlWorkflow", () => {
it("plans stale paused-run cleanup together with pause intent", () => {
const plan = planPauseRunControl({
approval: createApproval("approval-1"),
preferredAgentId: "agent-1",
agents: [
createAgent({ agentId: "agent-1", runId: "run-1" }),
createAgent({
agentId: "agent-2",
sessionKey: "agent:agent-2:main",
runId: "run-2",
}),
],
pausedRunIdByAgentId: new Map([
["agent-2", "stale-run"],
]),
});
expect(plan.stalePausedAgentIds).toEqual(["agent-2"]);
expect(plan.pauseIntent).toEqual({
kind: "pause",
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
runId: "run-1",
});
});
it("plans pre-wait and post-wait auto-resume intents", () => {
const plan = planAutoResumeRunControl({
approval: createApproval("approval-1"),
targetAgentId: "agent-1",
pendingState: createPendingState(),
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
agents: [createAgent({ status: "running", runId: "run-1" })],
});
expect(plan.preWaitIntent).toEqual({
kind: "resume",
targetAgentId: "agent-1",
pausedRunId: "run-1",
sessionKey: "agent:agent-1:main",
});
expect(plan.postWaitIntent).toEqual({
kind: "resume",
targetAgentId: "agent-1",
pausedRunId: "run-1",
sessionKey: "agent:agent-1:main",
});
});
it("returns skip intents when pre-wait auto-resume is blocked", () => {
const plan = planAutoResumeRunControl({
approval: createApproval("approval-1"),
targetAgentId: "agent-1",
pendingState: createPendingState({
approvalsByAgentId: {
"agent-1": [createApproval("approval-1"), createApproval("approval-2")],
},
}),
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
agents: [createAgent({ status: "running", runId: "run-1" })],
});
expect(plan.preWaitIntent).toEqual({
kind: "skip",
reason: "blocking-pending-approvals",
});
expect(plan.postWaitIntent).toEqual({
kind: "skip",
reason: "blocking-pending-approvals",
});
});
it("plans ingress run-control commands from gateway events", () => {
const event: EventFrame = {
type: "event",
event: "cron",
payload: {
action: "finished",
sessionKey: "agent:agent-1:main",
jobId: "job-1",
sessionId: "session-1",
runAtMs: 123,
status: "ok",
summary: "cron summary",
},
};
const commands = planApprovalIngressRunControl({
event,
agents: [createAgent()],
pendingState: createPendingState(),
pausedRunIdByAgentId: new Map(),
seenCronDedupeKeys: new Set(),
nowMs: 1_000,
});
expect(commands).toEqual([
{ kind: "recordCronDedupeKey", dedupeKey: "cron:job-1:session-1" },
{
kind: "appendCronTranscript",
intent: {
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
dedupeKey: "cron:job-1:session-1",
line: "Cron finished (ok): job-1\n\ncron summary",
timestampMs: 123,
activityAtMs: 123,
},
},
]);
});
});
@@ -0,0 +1,246 @@
import { describe, expect, it } from "vitest";
import type { ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
import type { PendingExecApproval } from "@/features/agents/approvals/types";
import {
applyApprovalIngressEffects,
deriveAwaitingUserInputPatches,
derivePendingApprovalPruneDelayMs,
prunePendingApprovalState,
resolveApprovalAutoResumeDispatch,
resolveApprovalAutoResumePreflight,
type ApprovalPendingState,
} from "@/features/agents/approvals/execApprovalRuntimeCoordinator";
import type { AgentState } from "@/features/agents/state/store";
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:main",
status: "running",
sessionCreated: true,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: "run-1",
runStartedAt: 1,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: true,
model: "openai/gpt-5",
thinkingLevel: "medium",
avatarSeed: "seed-1",
avatarUrl: null,
sessionExecAsk: "always",
...overrides,
});
const createApproval = (id: string, overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({
id,
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
command: "npm run test",
cwd: "/repo",
host: "gateway",
security: "allowlist",
ask: "always",
resolvedPath: "/usr/bin/npm",
createdAtMs: 1,
expiresAtMs: 10_000,
resolving: false,
error: null,
...overrides,
});
const createPendingState = (overrides?: Partial<ApprovalPendingState>): ApprovalPendingState => ({
approvalsByAgentId: {},
unscopedApprovals: [],
...overrides,
});
describe("execApprovalRuntimeCoordinator", () => {
it("applies scoped/unscoped upserts and removals while deriving pause requests", () => {
const existingScoped = createApproval("existing-scoped");
const existingUnscoped = createApproval("existing-unscoped", { agentId: null, sessionKey: "agent:other:main" });
const scopedUpsert = createApproval("approval-scoped", { ask: "always" });
const unscopedUpsert = createApproval("approval-unscoped", {
agentId: null,
sessionKey: "agent:other:main",
ask: "on-miss",
});
const pendingState = createPendingState({
approvalsByAgentId: { "agent-1": [existingScoped] },
unscopedApprovals: [existingUnscoped],
});
const effects: ExecApprovalEventEffects = {
scopedUpserts: [{ agentId: "agent-1", approval: scopedUpsert }],
unscopedUpserts: [unscopedUpsert],
removals: ["existing-scoped", "existing-unscoped"],
markActivityAgentIds: ["agent-1"],
};
const result = applyApprovalIngressEffects({
pendingState,
approvalEffects: effects,
agents: [createAgent(), createAgent({ agentId: "other", sessionKey: "agent:other:main", runId: "run-2", sessionExecAsk: "on-miss" })],
pausedRunIdByAgentId: new Map(),
});
expect(result.pendingState.approvalsByAgentId).toEqual({
"agent-1": [scopedUpsert],
});
expect(result.pendingState.unscopedApprovals).toEqual([unscopedUpsert]);
expect(result.markActivityAgentIds).toEqual(["agent-1"]);
expect(result.pauseRequests).toEqual([{ approval: scopedUpsert, preferredAgentId: "agent-1" }]);
});
it("does not emit pause request when run is already paused for the same run id", () => {
const scopedUpsert = createApproval("approval-scoped", { ask: "always" });
const effects: ExecApprovalEventEffects = {
scopedUpserts: [{ agentId: "agent-1", approval: scopedUpsert }],
unscopedUpserts: [],
removals: [],
markActivityAgentIds: [],
};
const result = applyApprovalIngressEffects({
pendingState: createPendingState(),
approvalEffects: effects,
agents: [createAgent({ runId: "run-1" })],
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
});
expect(result.pauseRequests).toEqual([]);
});
it("blocks preflight auto-resume when sibling pending approvals exist", () => {
const pendingState = createPendingState({
approvalsByAgentId: {
"agent-1": [createApproval("a-1"), createApproval("a-2")],
},
unscopedApprovals: [],
});
const preflight = resolveApprovalAutoResumePreflight({
approval: createApproval("a-1"),
targetAgentId: "agent-1",
pendingState,
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
});
expect(preflight).toEqual({ kind: "skip", reason: "blocking-pending-approvals" });
});
it("allows preflight auto-resume when no blocking approvals remain", () => {
const preflight = resolveApprovalAutoResumePreflight({
approval: createApproval("a-1"),
targetAgentId: "agent-1",
pendingState: createPendingState(),
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
});
expect(preflight).toEqual({
kind: "resume",
targetAgentId: "agent-1",
pausedRunId: "run-1",
});
});
it("derives dispatch auto-resume intent only when run ownership is still valid", () => {
const replacedRun = resolveApprovalAutoResumeDispatch({
targetAgentId: "agent-1",
pausedRunId: "run-1",
agents: [createAgent({ status: "running", runId: "run-2" })],
});
expect(replacedRun).toEqual({ kind: "skip", reason: "run-replaced" });
const resume = resolveApprovalAutoResumeDispatch({
targetAgentId: "agent-1",
pausedRunId: "run-1",
agents: [createAgent({ status: "running", runId: "run-1", sessionKey: "agent:agent-1:main" })],
});
expect(resume).toEqual({
kind: "resume",
targetAgentId: "agent-1",
pausedRunId: "run-1",
sessionKey: "agent:agent-1:main",
});
});
it("derives awaiting-user-input patches from scoped pending approvals", () => {
const agents = [
createAgent({ agentId: "agent-1", awaitingUserInput: false }),
createAgent({ agentId: "agent-2", awaitingUserInput: true, runId: "run-2", sessionKey: "agent:agent-2:main" }),
createAgent({ agentId: "agent-3", awaitingUserInput: false, runId: "run-3", sessionKey: "agent:agent-3:main" }),
];
const patches = deriveAwaitingUserInputPatches({
agents,
approvalsByAgentId: {
"agent-1": [createApproval("a-1")],
},
});
expect(patches).toEqual([
{ agentId: "agent-1", awaitingUserInput: true },
{ agentId: "agent-2", awaitingUserInput: false },
]);
});
it("derives prune delay and pruned pending state", () => {
const pendingState = createPendingState({
approvalsByAgentId: {
"agent-1": [createApproval("a-1", { expiresAtMs: 6_000 })],
},
unscopedApprovals: [createApproval("u-1", { agentId: null, expiresAtMs: 7_500 })],
});
const delay = derivePendingApprovalPruneDelayMs({
pendingState,
nowMs: 5_000,
graceMs: 500,
});
expect(delay).toBe(1_500);
const pruned = prunePendingApprovalState({
pendingState: {
approvalsByAgentId: {
"agent-1": [
createApproval("expired", { expiresAtMs: 4_000 }),
createApproval("active", { expiresAtMs: 6_000 }),
],
},
unscopedApprovals: [
createApproval("expired-u", { agentId: null, expiresAtMs: 4_100 }),
createApproval("active-u", { agentId: null, expiresAtMs: 8_000 }),
],
},
nowMs: 5_000,
graceMs: 500,
});
expect(pruned.pendingState.approvalsByAgentId).toEqual({
"agent-1": [createApproval("active", { expiresAtMs: 6_000 })],
});
expect(pruned.pendingState.unscopedApprovals).toEqual([
createApproval("active-u", { agentId: null, expiresAtMs: 8_000 }),
]);
});
});
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import {
resolveGatewayConfigRecord,
resolveGatewayModelsSyncIntent,
resolveSandboxRepairAgentIds,
resolveSandboxRepairIntent,
shouldRefreshGatewayConfigForSettingsRoute,
} from "@/features/agents/operations/gatewayConfigSyncWorkflow";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
describe("gatewayConfigSyncWorkflow", () => {
it("resolves config record only when snapshot config is an object", () => {
expect(resolveGatewayConfigRecord(null)).toBeNull();
expect(resolveGatewayConfigRecord({ config: [] } as unknown as GatewayModelPolicySnapshot)).toBeNull();
const snapshot: GatewayModelPolicySnapshot = {
config: {
agents: {
list: [{ id: "agent-1" }],
},
},
};
expect(resolveGatewayConfigRecord(snapshot)).toEqual(snapshot.config);
});
it("finds sandbox repair candidates with sandbox all mode and empty sandbox allowlist", () => {
const snapshot = {
config: {
agents: {
list: [
{
id: "agent-broken",
sandbox: { mode: "all" },
tools: { sandbox: { tools: { allow: [] } } },
},
{
id: "agent-ok-mode",
sandbox: { mode: "off" },
tools: { sandbox: { tools: { allow: [] } } },
},
{
id: "agent-ok-allow",
sandbox: { mode: "all" },
tools: { sandbox: { tools: { allow: ["*"] } } },
},
],
},
},
} as unknown as GatewayModelPolicySnapshot;
expect(resolveSandboxRepairAgentIds(snapshot)).toEqual(["agent-broken"]);
});
it("builds sandbox repair intent from status, attempt guard, and candidate list", () => {
const snapshot = {
config: {
agents: {
list: [
{
id: "agent-broken",
sandbox: { mode: "all" },
tools: { sandbox: { tools: { allow: [] } } },
},
],
},
},
} as unknown as GatewayModelPolicySnapshot;
expect(
resolveSandboxRepairIntent({
status: "disconnected",
attempted: false,
snapshot,
})
).toEqual({ kind: "skip", reason: "not-connected" });
expect(
resolveSandboxRepairIntent({
status: "connected",
attempted: true,
snapshot,
})
).toEqual({ kind: "skip", reason: "already-attempted" });
expect(
resolveSandboxRepairIntent({
status: "connected",
attempted: false,
snapshot,
})
).toEqual({ kind: "repair", agentIds: ["agent-broken"] });
});
it("gates settings-route refresh on route flag, inspect agent id, and connected status", () => {
expect(
shouldRefreshGatewayConfigForSettingsRoute({
status: "connected",
settingsRouteActive: false,
inspectSidebarAgentId: "agent-1",
})
).toBe(false);
expect(
shouldRefreshGatewayConfigForSettingsRoute({
status: "connected",
settingsRouteActive: true,
inspectSidebarAgentId: null,
})
).toBe(false);
expect(
shouldRefreshGatewayConfigForSettingsRoute({
status: "connecting",
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
})
).toBe(false);
expect(
shouldRefreshGatewayConfigForSettingsRoute({
status: "connected",
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
})
).toBe(true);
});
it("returns model sync load intent only when connected", () => {
expect(resolveGatewayModelsSyncIntent({ status: "connected" })).toEqual({ kind: "load" });
expect(resolveGatewayModelsSyncIntent({ status: "connecting" })).toEqual({ kind: "clear" });
expect(resolveGatewayModelsSyncIntent({ status: "disconnected" })).toEqual({ kind: "clear" });
});
});
@@ -257,6 +257,56 @@ describe("gateway runtime event handler (chat)", () => {
expect(clearPendingLivePatch).toHaveBeenCalledWith("agent-1");
});
it("uses the current chat agent snapshot for latest-update effects", () => {
const agents = [
createAgent({
lastUserMessage: "hello",
latestOverride: null,
status: "running",
runId: "run-1",
runStartedAt: 900,
}),
];
let getAgentsCalls = 0;
const getAgents = () => {
getAgentsCalls += 1;
return getAgentsCalls === 1 ? agents : [];
};
const updateSpecialLatestUpdate = vi.fn();
const handler = createGatewayRuntimeEventHandler({
getStatus: () => "connected",
getAgents,
dispatch: vi.fn(),
queueLivePatch: vi.fn(),
clearPendingLivePatch: vi.fn(),
now: () => 1000,
loadSummarySnapshot: vi.fn(async () => {}),
requestHistoryRefresh: vi.fn(async () => {}),
refreshHeartbeatLatestUpdate: vi.fn(),
bumpHeartbeatTick: vi.fn(),
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
isDisconnectLikeError: () => false,
logWarn: vi.fn(),
updateSpecialLatestUpdate,
});
handler.handleEvent({
type: "event",
event: "chat",
payload: {
runId: "run-1",
sessionKey: agents[0]!.sessionKey,
state: "final",
message: { role: "assistant", content: "Done", timestamp: "2024-01-01T00:00:00.000Z" },
},
});
expect(updateSpecialLatestUpdate).toHaveBeenCalledTimes(1);
expect(updateSpecialLatestUpdate).toHaveBeenCalledWith("agent-1", agents[0], "hello");
});
it("normalizes markdown-rich final assistant chat text before append and lastResult update", () => {
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
const dispatched: Array<{ type: string; line?: string; patch?: unknown }> = [];
@@ -0,0 +1,328 @@
import { describe, expect, it } from "vitest";
import type { AgentState } from "@/features/agents/state/store";
import {
planRuntimeAgentEvent,
type RuntimeAgentWorkflowCommand,
type RuntimeAgentWorkflowInput,
} from "@/features/agents/state/runtimeAgentEventWorkflow";
import type { RuntimePolicyIntent } from "@/features/agents/state/runtimeEventPolicy";
import type { AgentEventPayload } from "@/features/agents/state/runtimeEventBridge";
import {
createRuntimeTerminalState,
markClosedRun,
type RuntimeTerminalCommand,
type RuntimeTerminalState,
} from "@/features/agents/state/runtimeTerminalWorkflow";
type InputOverrides = Partial<Omit<RuntimeAgentWorkflowInput, "payload" | "agent">> & {
payload?: AgentEventPayload;
agent?: AgentState;
};
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:studio:test-session",
status: "running",
sessionCreated: true,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: "run-1",
runStartedAt: null,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: true,
model: "openai/gpt-5",
thinkingLevel: "medium",
avatarSeed: "seed-1",
avatarUrl: null,
...(overrides ?? {}),
});
const createPayload = (overrides?: Partial<AgentEventPayload>): AgentEventPayload => ({
runId: "run-1",
sessionKey: "agent:agent-1:studio:test-session",
stream: "assistant",
data: { delta: "hello" },
...(overrides ?? {}),
});
const createInput = (overrides?: InputOverrides): RuntimeAgentWorkflowInput => ({
payload: overrides?.payload ?? createPayload(),
agent: overrides?.agent ?? createAgent(),
activeRunId: "run-1",
nowMs: 1000,
runtimeTerminalState:
overrides?.runtimeTerminalState ?? (createRuntimeTerminalState() as RuntimeTerminalState),
hasChatEvents: false,
hasPendingFallbackTimer: false,
previousThinkingRaw: null,
previousAssistantRaw: null,
thinkingStartedAtMs: null,
historyRefreshRequested: false,
lifecycleFallbackDelayMs: 0,
...(overrides ?? {}),
});
const findCommand = <TKind extends RuntimeAgentWorkflowCommand["kind"]>(
commands: RuntimeAgentWorkflowCommand[],
kind: TKind
): Extract<RuntimeAgentWorkflowCommand, { kind: TKind }> | undefined =>
commands.find((command) => command.kind === kind) as
| Extract<RuntimeAgentWorkflowCommand, { kind: TKind }>
| undefined;
const findIntent = <TKind extends RuntimePolicyIntent["kind"]>(
intents: RuntimePolicyIntent[],
kind: TKind
): Extract<RuntimePolicyIntent, { kind: TKind }> | undefined =>
intents.find((intent) => intent.kind === kind) as
| Extract<RuntimePolicyIntent, { kind: TKind }>
| undefined;
const hasTerminalCommand = (
commands: RuntimeTerminalCommand[],
kind: RuntimeTerminalCommand["kind"]
): boolean => commands.some((command) => command.kind === kind);
describe("runtime agent event workflow", () => {
it("returns preflight cleanup intents when incoming run is stale", () => {
const result = planRuntimeAgentEvent(
createInput({
payload: createPayload({ runId: "run-stale", stream: "assistant", data: { delta: "x" } }),
activeRunId: "run-active",
})
);
expect(result.commands).toEqual([
{
kind: "applyPolicyIntents",
intents: [{ kind: "clearRunTracking", runId: "run-stale" }],
},
]);
});
it("logs late-event metric for closed-run preflight ignore", () => {
const closedState = markClosedRun(createRuntimeTerminalState(), {
runId: "run-1",
now: 500,
ttlMs: 10_000,
});
const result = planRuntimeAgentEvent(
createInput({
runtimeTerminalState: closedState,
})
);
expect(result.commands).toEqual([
{
kind: "logMetric",
metric: "late_event_ignored_closed_run",
meta: {
stream: "assistant",
runId: "run-1",
},
},
]);
});
it("plans reasoning stream cache update and thinking live patch", () => {
const result = planRuntimeAgentEvent(
createInput({
payload: createPayload({ stream: "reasoning", data: { text: "thinking out loud" } }),
agent: createAgent({ runStartedAt: null }),
})
);
expect(findCommand(result.commands, "markActivity")).toEqual({
kind: "markActivity",
at: 1000,
});
expect(findCommand(result.commands, "setThinkingStreamRaw")).toEqual({
kind: "setThinkingStreamRaw",
runId: "run-1",
raw: "thinking out loud",
});
expect(findCommand(result.commands, "markThinkingStarted")).toEqual({
kind: "markThinkingStarted",
runId: "run-1",
at: 1000,
});
expect(findCommand(result.commands, "queueAgentPatch")).toEqual({
kind: "queueAgentPatch",
patch: {
status: "running",
runId: "run-1",
runStartedAt: 1000,
sessionCreated: true,
lastActivityAt: 1000,
thinkingTrace: "thinking out loud",
},
});
});
it("suppresses assistant streamText patch when chat stream owns transcript", () => {
const result = planRuntimeAgentEvent(
createInput({
payload: createPayload({ stream: "assistant", data: { delta: "hello" } }),
agent: createAgent({ streamText: "already streaming" }),
hasChatEvents: true,
})
);
const queue = findCommand(result.commands, "queueAgentPatch");
expect(queue).toBeDefined();
expect(queue?.patch.status).toBe("running");
expect(queue?.patch.runId).toBe("run-1");
expect("streamText" in (queue?.patch ?? {})).toBe(false);
});
it("extends assistant streamText when incoming stream advances current text", () => {
const result = planRuntimeAgentEvent(
createInput({
payload: createPayload({ stream: "assistant", data: { delta: "hello world" } }),
agent: createAgent({ streamText: "hello" }),
hasChatEvents: true,
previousAssistantRaw: "hello",
})
);
const queue = findCommand(result.commands, "queueAgentPatch");
expect(queue).toBeDefined();
expect(queue?.patch.streamText).toBe("hello world");
});
it("plans tool call line append", () => {
const result = planRuntimeAgentEvent(
createInput({
payload: createPayload({
stream: "tool",
data: {
phase: "call",
name: "myTool",
toolCallId: "tool-1",
arguments: { a: 1 },
},
}),
})
);
const append = findCommand(result.commands, "appendToolLines");
expect(append).toBeDefined();
expect(append?.lines).toHaveLength(1);
expect(append?.lines[0]).toContain("[[tool]] myTool (tool-1)");
});
it("plans tool result append and one-time history refresh", () => {
const result = planRuntimeAgentEvent(
createInput({
payload: createPayload({
stream: "tool",
data: {
phase: "result",
name: "exec",
toolCallId: "tool-2",
result: { content: [{ type: "text", text: "ok" }] },
},
}),
historyRefreshRequested: false,
})
);
const append = findCommand(result.commands, "appendToolLines");
expect(append).toBeDefined();
expect(append?.lines.some((line) => line.startsWith("[[tool-result]]"))).toBe(true);
expect(findCommand(result.commands, "markHistoryRefreshRequested")).toEqual({
kind: "markHistoryRefreshRequested",
runId: "run-1",
});
expect(findCommand(result.commands, "scheduleHistoryRefresh")).toEqual({
kind: "scheduleHistoryRefresh",
delayMs: 750,
reason: "chat-final-no-trace",
});
});
it("plans lifecycle decision with deferred transition patch when fallback is scheduled", () => {
const result = planRuntimeAgentEvent(
createInput({
payload: createPayload({
stream: "lifecycle",
data: { phase: "end" },
}),
agent: createAgent({ streamText: "final text", runId: "run-1" }),
})
);
const lifecycle = findCommand(result.commands, "applyLifecycleDecision");
expect(lifecycle).toBeDefined();
expect(lifecycle?.shouldClearPendingLivePatch).toBe(true);
expect(lifecycle?.decision.deferTransitionPatch).toBe(true);
expect(hasTerminalCommand(lifecycle?.decision.commands ?? [], "cancelLifecycleFallback")).toBe(
true
);
expect(
hasTerminalCommand(
lifecycle?.decision.commands ?? [],
"scheduleLifecycleFallback"
)
).toBe(true);
});
it("does not request history refresh when tool result already requested once", () => {
const result = planRuntimeAgentEvent(
createInput({
payload: createPayload({
stream: "tool",
data: {
phase: "result",
name: "exec",
toolCallId: "tool-3",
result: { content: [{ type: "text", text: "ok" }] },
},
}),
historyRefreshRequested: true,
})
);
expect(findCommand(result.commands, "markHistoryRefreshRequested")).toBeUndefined();
expect(findCommand(result.commands, "scheduleHistoryRefresh")).toBeUndefined();
});
it("keeps preflight intents empty for active lifecycle start and emits activity command", () => {
const result = planRuntimeAgentEvent(
createInput({
payload: createPayload({
stream: "lifecycle",
data: { phase: "start" },
}),
})
);
expect(findCommand(result.commands, "markActivity")).toEqual({
kind: "markActivity",
at: 1000,
});
const lifecycle = findCommand(result.commands, "applyLifecycleDecision");
expect(lifecycle).toBeDefined();
expect(findIntent([], "clearRunTracking")).toBeUndefined();
});
});
+328
View File
@@ -0,0 +1,328 @@
import { describe, expect, it } from "vitest";
import type { AgentState } from "@/features/agents/state/store";
import {
planRuntimeChatEvent,
type RuntimeChatWorkflowCommand,
type RuntimeChatWorkflowInput,
} from "@/features/agents/state/runtimeChatEventWorkflow";
import type { RuntimePolicyIntent } from "@/features/agents/state/runtimeEventPolicy";
import type { ChatEventPayload } from "@/features/agents/state/runtimeEventBridge";
import {
applyTerminalCommit,
createRuntimeTerminalState,
type RuntimeTerminalState,
} from "@/features/agents/state/runtimeTerminalWorkflow";
type InputOverrides = Partial<Omit<RuntimeChatWorkflowInput, "payload" | "agent">> & {
payload?: ChatEventPayload;
agent?: AgentState | undefined;
};
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
const base: AgentState = {
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:studio:test-session",
status: "running",
sessionCreated: true,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: "run-1",
runStartedAt: null,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: true,
model: "openai/gpt-5",
thinkingLevel: "medium",
avatarSeed: "seed-1",
avatarUrl: null,
};
return {
...base,
...(overrides ?? {}),
};
};
const createPayload = (overrides?: Partial<ChatEventPayload>): ChatEventPayload => ({
runId: "run-1",
sessionKey: "agent:agent-1:studio:test-session",
state: "delta",
message: { role: "assistant", content: "Hello" },
...(overrides ?? {}),
});
const createInput = (overrides?: InputOverrides): RuntimeChatWorkflowInput => ({
payload: overrides?.payload ?? createPayload(),
agentId: "agent-1",
agent: overrides?.agent ?? createAgent(),
activeRunId: "run-1",
runtimeTerminalState:
overrides?.runtimeTerminalState ?? (createRuntimeTerminalState() as RuntimeTerminalState),
role: "assistant",
nowMs: 1000,
nextTextRaw: "Hello",
nextText: "Hello",
nextThinking: null,
toolLines: [],
isToolRole: false,
assistantCompletionAt: null,
finalAssistantText: null,
hasThinkingStarted: false,
hasTraceInOutput: false,
isThinkingDebugSessionSeen: false,
thinkingStartedAtMs: null,
...(overrides ?? {}),
});
const findCommand = <TKind extends RuntimeChatWorkflowCommand["kind"]>(
commands: RuntimeChatWorkflowCommand[],
kind: TKind
): Extract<RuntimeChatWorkflowCommand, { kind: TKind }> | undefined =>
commands.find((command) => command.kind === kind) as
| Extract<RuntimeChatWorkflowCommand, { kind: TKind }>
| undefined;
const findIntent = <TKind extends RuntimePolicyIntent["kind"]>(
intents: RuntimePolicyIntent[],
kind: TKind
): Extract<RuntimePolicyIntent, { kind: TKind }> | undefined =>
intents.find((intent) => intent.kind === kind) as
| Extract<RuntimePolicyIntent, { kind: TKind }>
| undefined;
describe("runtime chat event workflow", () => {
it("ignores delta events that begin with UI metadata", () => {
const result = planRuntimeChatEvent(
createInput({
nextTextRaw: "Project path: /tmp/work",
nextText: "Project path: /tmp/work",
})
);
expect(result.commands).toEqual([]);
});
it("plans delta intents and tool append commands", () => {
const result = planRuntimeChatEvent(
createInput({
nextThinking: "think",
toolLines: ["[[tool]] call"],
})
);
const policy = findCommand(result.commands, "applyPolicyIntents");
expect(policy).toBeDefined();
expect(findIntent(policy?.intents ?? [], "markThinkingStarted")).toEqual({
kind: "markThinkingStarted",
runId: "run-1",
at: 1000,
});
expect(findIntent(policy?.intents ?? [], "queueLivePatch")).toEqual({
kind: "queueLivePatch",
agentId: "agent-1",
patch: {
thinkingTrace: "think",
streamText: "Hello",
status: "running",
runId: "run-1",
runStartedAt: 1000,
},
});
expect(findCommand(result.commands, "appendToolLines")).toEqual({
kind: "appendToolLines",
lines: ["[[tool]] call"],
timestampMs: 1000,
});
});
it("plans final assistant completion with fallback replacement metrics", () => {
const runtimeTerminalState = applyTerminalCommit(createRuntimeTerminalState(), {
runId: "run-1",
source: "lifecycle-fallback",
seq: null,
});
const result = planRuntimeChatEvent(
createInput({
payload: createPayload({ state: "final", seq: 7 }),
runtimeTerminalState,
nowMs: 2200,
nextTextRaw: "Done",
nextText: "Done",
nextThinking: "first\nsecond",
assistantCompletionAt: 2100,
finalAssistantText: "Done",
hasThinkingStarted: true,
thinkingStartedAtMs: 2000,
})
);
const terminalDecision = findCommand(result.commands, "applyChatTerminalDecision");
expect(terminalDecision?.decision.fallbackCommittedBeforeFinal).toBe(true);
expect(
result.commands.some(
(command) =>
command.kind === "logMetric" &&
command.metric === "lifecycle_fallback_replaced_by_chat_final"
)
).toBe(true);
expect(
result.commands.some(
(command) =>
command.kind === "appendOutput" &&
command.transcript.kind === "meta" &&
command.transcript.timestampMs === 2100 &&
command.line.startsWith("[[meta]]")
)
).toBe(true);
expect(
result.commands.some(
(command) =>
command.kind === "appendOutput" &&
command.transcript.kind === "thinking" &&
command.line.startsWith("[[trace]]")
)
).toBe(true);
expect(
result.commands.some(
(command) =>
command.kind === "appendOutput" &&
command.transcript.kind === "assistant" &&
command.line === "Done"
)
).toBe(true);
expect(findCommand(result.commands, "applyTerminalCommit")).toEqual({
kind: "applyTerminalCommit",
runId: "run-1",
seq: 7,
});
const policy = findCommand(result.commands, "applyPolicyIntents");
expect(policy).toBeDefined();
expect(findIntent(policy?.intents ?? [], "clearPendingLivePatch")).toEqual({
kind: "clearPendingLivePatch",
agentId: "agent-1",
});
expect(findIntent(policy?.intents ?? [], "markRunClosed")).toEqual({
kind: "markRunClosed",
runId: "run-1",
});
});
it("returns only stale-terminal diagnostics for stale final events", () => {
const runtimeTerminalState = applyTerminalCommit(createRuntimeTerminalState(), {
runId: "run-1",
source: "chat-final",
seq: 4,
});
const result = planRuntimeChatEvent(
createInput({
payload: createPayload({ state: "final", seq: 4 }),
runtimeTerminalState,
nextTextRaw: "Done",
nextText: "Done",
assistantCompletionAt: 2000,
finalAssistantText: "Done",
})
);
expect(result.commands).toHaveLength(2);
expect(result.commands[0]).toMatchObject({ kind: "applyChatTerminalDecision" });
expect(result.commands[1]).toMatchObject({
kind: "logMetric",
metric: "stale_terminal_chat_event_ignored",
});
});
it("plans missing-thinking diagnostics and history refresh for assistant final", () => {
const result = planRuntimeChatEvent(
createInput({
payload: createPayload({ state: "final", seq: 1 }),
nextTextRaw: "Done",
nextText: "Done",
nextThinking: null,
assistantCompletionAt: 2000,
finalAssistantText: "Done",
})
);
expect(findCommand(result.commands, "markThinkingDebugSession")).toEqual({
kind: "markThinkingDebugSession",
sessionKey: "agent:agent-1:studio:test-session",
});
const warn = findCommand(result.commands, "logWarn");
expect(warn).toBeDefined();
expect(warn?.message).toBe("No thinking trace extracted from chat event.");
const policy = findCommand(result.commands, "applyPolicyIntents");
expect(policy).toBeDefined();
expect(findIntent(policy?.intents ?? [], "requestHistoryRefresh")).toEqual({
kind: "requestHistoryRefresh",
agentId: "agent-1",
reason: "chat-final-no-trace",
});
});
it("plans aborted output command and policy intents", () => {
const result = planRuntimeChatEvent(
createInput({
payload: createPayload({ state: "aborted" }),
})
);
expect(result.commands).toEqual([
{ kind: "appendAbortedIfNotSuppressed", timestampMs: 1000 },
expect.objectContaining({ kind: "applyPolicyIntents" }),
]);
});
it("plans error output with error-state policy intents", () => {
const result = planRuntimeChatEvent(
createInput({
payload: createPayload({ state: "error", errorMessage: "boom" }),
})
);
expect(result.commands[0]).toEqual(
expect.objectContaining({
kind: "appendOutput",
line: "Error: boom",
})
);
const policy = findCommand(result.commands, "applyPolicyIntents");
expect(policy).toBeDefined();
expect(
(policy?.intents ?? []).some(
(intent) =>
intent.kind === "dispatchUpdateAgent" &&
intent.patch.status === "error" &&
intent.patch.runId === null
)
).toBe(true);
});
});
@@ -0,0 +1,366 @@
import { describe, expect, it } from "vitest";
import type { AgentState } from "@/features/agents/state/store";
import {
createRuntimeEventCoordinatorState,
markChatRunSeen,
reduceClearRunTracking,
reduceLifecycleFallbackFired,
reduceMarkActivityThrottled,
reduceRuntimeAgentWorkflowCommands,
reduceRuntimePolicyIntents,
} from "@/features/agents/state/runtimeEventCoordinatorWorkflow";
import {
applyTerminalCommit,
createRuntimeTerminalState,
deriveLifecycleTerminalDecision,
} from "@/features/agents/state/runtimeTerminalWorkflow";
import type { AgentEventPayload } from "@/features/agents/state/runtimeEventBridge";
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:studio:test-session",
status: "running",
sessionCreated: true,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: "run-1",
runStartedAt: 100,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: true,
model: "openai/gpt-5",
thinkingLevel: "medium",
avatarSeed: "seed-1",
avatarUrl: null,
...(overrides ?? {}),
});
const createAgentPayload = (overrides?: Partial<AgentEventPayload>): AgentEventPayload => ({
runId: "run-1",
sessionKey: "agent:agent-1:studio:test-session",
stream: "assistant",
data: { delta: "hello" },
...(overrides ?? {}),
});
describe("runtimeEventCoordinatorWorkflow", () => {
it("reduces runtime policy intents into effects and run cleanup", () => {
let state = createRuntimeEventCoordinatorState();
state = markChatRunSeen(state, "run-1");
state.thinkingStartedAtByRun.set("run-1", 900);
const reduced = reduceRuntimePolicyIntents({
state,
nowMs: 1000,
intents: [
{ kind: "queueLivePatch", agentId: "agent-1", patch: { streamText: "stream" } },
{
kind: "dispatchUpdateAgent",
agentId: "agent-1",
patch: { status: "running", runId: "run-1" },
},
{
kind: "requestHistoryRefresh",
agentId: "agent-1",
reason: "chat-final-no-trace",
},
{
kind: "scheduleSummaryRefresh",
delayMs: 750,
includeHeartbeatRefresh: true,
},
{ kind: "clearRunTracking", runId: "run-1" },
],
});
expect(reduced.effects).toEqual(
expect.arrayContaining([
{
kind: "queueLivePatch",
agentId: "agent-1",
patch: { streamText: "stream" },
},
{
kind: "dispatch",
action: {
type: "updateAgent",
agentId: "agent-1",
patch: { status: "running", runId: "run-1" },
},
},
{
kind: "requestHistoryRefresh",
agentId: "agent-1",
reason: "chat-final-no-trace",
deferMs: 0,
},
{
kind: "scheduleSummaryRefresh",
delayMs: 750,
includeHeartbeatRefresh: true,
},
{
kind: "cancelLifecycleFallback",
runId: "run-1",
},
])
);
expect(reduced.state.chatRunSeen.has("run-1")).toBe(false);
expect(reduced.state.thinkingStartedAtByRun.has("run-1")).toBe(false);
});
it("reduces lifecycle decision into fallback scheduling/cancellation effects", () => {
const initial = createRuntimeEventCoordinatorState();
const scheduleDecision = deriveLifecycleTerminalDecision({
mode: "event",
state: initial.runtimeTerminalState,
runId: "run-1",
phase: "end",
hasPendingFallbackTimer: false,
fallbackDelayMs: 250,
fallbackFinalText: "fallback final",
transitionClearsRunTracking: true,
});
const scheduleReduced = reduceRuntimeAgentWorkflowCommands({
state: initial,
payload: createAgentPayload({
stream: "lifecycle",
data: { phase: "end" },
}),
agentId: "agent-1",
agent: createAgent(),
nowMs: 1000,
commands: [
{
kind: "applyLifecycleDecision",
decision: scheduleDecision,
transitionPatch: { status: "idle", runId: null },
shouldClearPendingLivePatch: true,
},
],
});
expect(scheduleReduced.effects).toEqual(
expect.arrayContaining([
{ kind: "clearPendingLivePatch", agentId: "agent-1" },
{ kind: "cancelLifecycleFallback", runId: "run-1" },
{
kind: "scheduleLifecycleFallback",
runId: "run-1",
delayMs: 250,
agentId: "agent-1",
sessionKey: "agent:agent-1:studio:test-session",
finalText: "fallback final",
transitionPatch: { status: "idle", runId: null },
},
])
);
expect(
scheduleReduced.effects.some(
(effect) =>
effect.kind === "dispatch" &&
effect.action.type === "updateAgent" &&
effect.action.patch.status === "idle"
)
).toBe(false);
const cancelDecision = deriveLifecycleTerminalDecision({
mode: "event",
state: initial.runtimeTerminalState,
runId: "run-2",
phase: "start",
hasPendingFallbackTimer: true,
fallbackDelayMs: 250,
fallbackFinalText: null,
transitionClearsRunTracking: false,
});
const cancelReduced = reduceRuntimeAgentWorkflowCommands({
state: initial,
payload: createAgentPayload({ runId: "run-2", stream: "lifecycle", data: { phase: "start" } }),
agentId: "agent-1",
agent: createAgent({ runId: "run-2" }),
nowMs: 1000,
commands: [
{
kind: "applyLifecycleDecision",
decision: cancelDecision,
transitionPatch: { status: "running", runId: "run-2" },
shouldClearPendingLivePatch: false,
},
],
});
expect(
cancelReduced.effects.some(
(effect) => effect.kind === "cancelLifecycleFallback" && effect.runId === "run-2"
)
).toBe(true);
});
it("applies fallback-fired commits only when chat final has not already committed", () => {
const baseDecision = deriveLifecycleTerminalDecision({
mode: "event",
state: createRuntimeTerminalState(),
runId: "run-1",
phase: "end",
hasPendingFallbackTimer: false,
fallbackDelayMs: 0,
fallbackFinalText: "fallback final",
transitionClearsRunTracking: true,
});
const state = {
...createRuntimeEventCoordinatorState(),
runtimeTerminalState: baseDecision.state,
thinkingStartedAtByRun: new Map<string, number>([["run-1", 1000]]),
};
const committed = reduceLifecycleFallbackFired({
state,
runId: "run-1",
agentId: "agent-1",
sessionKey: "agent:agent-1:studio:test-session",
finalText: "fallback final",
transitionPatch: { status: "idle", runId: null },
nowMs: 1300,
});
expect(
committed.effects.some(
(effect) =>
effect.kind === "dispatch" &&
effect.action.type === "appendOutput" &&
effect.action.transcript?.kind === "meta"
)
).toBe(true);
expect(
committed.effects.some(
(effect) =>
effect.kind === "dispatch" &&
effect.action.type === "appendOutput" &&
effect.action.line === "fallback final"
)
).toBe(true);
expect(
committed.effects.some(
(effect) =>
effect.kind === "dispatch" &&
effect.action.type === "updateAgent" &&
effect.action.patch.lastResult === "fallback final"
)
).toBe(true);
const chatFinalCommittedState = {
...state,
runtimeTerminalState: applyTerminalCommit(state.runtimeTerminalState, {
runId: "run-1",
source: "chat-final",
seq: 1,
}),
};
const skipped = reduceLifecycleFallbackFired({
state: chatFinalCommittedState,
runId: "run-1",
agentId: "agent-1",
sessionKey: "agent:agent-1:studio:test-session",
finalText: "fallback final",
transitionPatch: { status: "idle", runId: null },
nowMs: 1400,
});
expect(skipped.effects).toEqual([]);
});
it("tracks history refresh state per run and clears it with run cleanup", () => {
const reduced = reduceRuntimeAgentWorkflowCommands({
state: createRuntimeEventCoordinatorState(),
payload: createAgentPayload({
stream: "tool",
data: { phase: "result" },
}),
agentId: "agent-1",
agent: createAgent(),
nowMs: 2000,
commands: [
{ kind: "markHistoryRefreshRequested", runId: "run-1" },
{
kind: "scheduleHistoryRefresh",
delayMs: 750,
reason: "chat-final-no-trace",
},
],
});
expect(reduced.state.historyRefreshRequestedByRun.has("run-1")).toBe(true);
expect(reduced.effects).toContainEqual({
kind: "requestHistoryRefresh",
agentId: "agent-1",
reason: "chat-final-no-trace",
deferMs: 750,
});
const cleared = reduceClearRunTracking({ state: reduced.state, runId: "run-1" });
expect(cleared.state.historyRefreshRequestedByRun.has("run-1")).toBe(false);
expect(cleared.effects).toContainEqual({
kind: "cancelLifecycleFallback",
runId: "run-1",
});
});
it("throttles mark-activity effects by agent", () => {
const first = reduceMarkActivityThrottled({
state: createRuntimeEventCoordinatorState(),
agentId: "agent-1",
at: 1000,
});
expect(first.effects).toContainEqual({
kind: "dispatch",
action: {
type: "markActivity",
agentId: "agent-1",
at: 1000,
},
});
const second = reduceMarkActivityThrottled({
state: first.state,
agentId: "agent-1",
at: 1100,
});
expect(second.effects).toEqual([]);
const third = reduceMarkActivityThrottled({
state: second.state,
agentId: "agent-1",
at: 1301,
});
expect(third.effects).toContainEqual({
kind: "dispatch",
action: {
type: "markActivity",
agentId: "agent-1",
at: 1301,
},
});
});
});
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
RUNTIME_SYNC_FOCUSED_HISTORY_INTERVAL_MS,
RUNTIME_SYNC_RECONCILE_INTERVAL_MS,
resolveRuntimeSyncBootstrapHistoryAgentIds,
resolveRuntimeSyncFocusedHistoryPollingIntent,
resolveRuntimeSyncGapRecoveryIntent,
resolveRuntimeSyncLoadMoreHistoryLimit,
resolveRuntimeSyncReconcilePollingIntent,
shouldRuntimeSyncContinueFocusedHistoryPolling,
} from "@/features/agents/operations/runtimeSyncControlWorkflow";
describe("runtimeSyncControlWorkflow", () => {
it("plans reconcile polling only when connected", () => {
expect(
resolveRuntimeSyncReconcilePollingIntent({
status: "disconnected",
})
).toEqual({
kind: "stop",
reason: "not-connected",
});
expect(
resolveRuntimeSyncReconcilePollingIntent({
status: "connected",
})
).toEqual({
kind: "start",
intervalMs: RUNTIME_SYNC_RECONCILE_INTERVAL_MS,
runImmediately: true,
});
});
it("plans history bootstrap for connected unloaded sessions", () => {
expect(
resolveRuntimeSyncBootstrapHistoryAgentIds({
status: "connected",
agents: [
{ agentId: "agent-1", sessionCreated: true, historyLoadedAt: null },
{ agentId: "agent-2", sessionCreated: true, historyLoadedAt: 1234 },
{ agentId: "agent-3", sessionCreated: false, historyLoadedAt: null },
],
})
).toEqual(["agent-1"]);
expect(
resolveRuntimeSyncBootstrapHistoryAgentIds({
status: "connecting",
agents: [{ agentId: "agent-1", sessionCreated: true, historyLoadedAt: null }],
})
).toEqual([]);
});
it("plans focused history polling with explicit stop reasons", () => {
expect(
resolveRuntimeSyncFocusedHistoryPollingIntent({
status: "connected",
focusedAgentId: "agent-1",
focusedAgentRunning: true,
})
).toEqual({
kind: "start",
agentId: "agent-1",
intervalMs: RUNTIME_SYNC_FOCUSED_HISTORY_INTERVAL_MS,
runImmediately: true,
});
expect(
resolveRuntimeSyncFocusedHistoryPollingIntent({
status: "connected",
focusedAgentId: null,
focusedAgentRunning: true,
})
).toEqual({
kind: "stop",
reason: "missing-focused-agent",
});
expect(
resolveRuntimeSyncFocusedHistoryPollingIntent({
status: "connected",
focusedAgentId: "agent-1",
focusedAgentRunning: false,
})
).toEqual({
kind: "stop",
reason: "focused-not-running",
});
});
it("checks focused polling continuation against latest running state", () => {
expect(
shouldRuntimeSyncContinueFocusedHistoryPolling({
agentId: "agent-1",
agents: [{ agentId: "agent-1", status: "running" }],
})
).toBe(true);
expect(
shouldRuntimeSyncContinueFocusedHistoryPolling({
agentId: "agent-1",
agents: [{ agentId: "agent-1", status: "idle" }],
})
).toBe(false);
expect(
shouldRuntimeSyncContinueFocusedHistoryPolling({
agentId: "agent-1",
agents: [],
})
).toBe(false);
});
it("resolves load-more limits with floor and max bounds", () => {
expect(
resolveRuntimeSyncLoadMoreHistoryLimit({
currentLimit: 200,
defaultLimit: 200,
maxLimit: 5000,
})
).toBe(400);
expect(
resolveRuntimeSyncLoadMoreHistoryLimit({
currentLimit: 3000,
defaultLimit: 200,
maxLimit: 5000,
})
).toBe(5000);
expect(
resolveRuntimeSyncLoadMoreHistoryLimit({
currentLimit: null,
defaultLimit: 200,
maxLimit: 5000,
})
).toBe(400);
});
it("always plans summary refresh plus reconcile for gap recovery", () => {
expect(resolveRuntimeSyncGapRecoveryIntent()).toEqual({
refreshSummarySnapshot: true,
reconcileRunningAgents: true,
});
});
});
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import {
applyTerminalCommit,
createRuntimeTerminalState,
deriveChatTerminalDecision,
deriveLifecycleTerminalDecision,
isClosedRun,
markClosedRun,
pruneClosedRuns,
} from "@/features/agents/state/runtimeTerminalWorkflow";
describe("runtime terminal workflow", () => {
it("marks same-or-lower chat final sequence as stale, but accepts higher sequence", () => {
let state = createRuntimeTerminalState();
state = applyTerminalCommit(state, {
runId: "run-1",
source: "chat-final",
seq: 4,
});
const sameSeq = deriveChatTerminalDecision({
state,
runId: "run-1",
isFinal: true,
seq: 4,
});
expect(sameSeq.isStaleTerminal).toBe(true);
expect(sameSeq.lastTerminalSeqBeforeFinal).toBe(4);
expect(sameSeq.commitSourceBeforeFinal).toBe("chat-final");
const lowerSeq = deriveChatTerminalDecision({
state,
runId: "run-1",
isFinal: true,
seq: 3,
});
expect(lowerSeq.isStaleTerminal).toBe(true);
const higherSeq = deriveChatTerminalDecision({
state,
runId: "run-1",
isFinal: true,
seq: 5,
});
expect(higherSeq.isStaleTerminal).toBe(false);
});
it("schedules lifecycle fallback only when lifecycle end arrives before chat final", () => {
const freshState = createRuntimeTerminalState();
const pendingFallback = deriveLifecycleTerminalDecision({
mode: "event",
state: freshState,
runId: "run-2",
phase: "end",
hasPendingFallbackTimer: false,
fallbackDelayMs: 250,
fallbackFinalText: "fallback final",
transitionClearsRunTracking: true,
});
expect(pendingFallback.deferTransitionPatch).toBe(true);
expect(pendingFallback.commands).toEqual(
expect.arrayContaining([
{ kind: "cancelLifecycleFallback", runId: "run-2" },
{
kind: "scheduleLifecycleFallback",
runId: "run-2",
delayMs: 250,
finalText: "fallback final",
},
])
);
let chatSeenState = createRuntimeTerminalState();
chatSeenState = applyTerminalCommit(chatSeenState, {
runId: "run-2",
source: "chat-final",
seq: 1,
});
const noFallback = deriveLifecycleTerminalDecision({
mode: "event",
state: chatSeenState,
runId: "run-2",
phase: "end",
hasPendingFallbackTimer: false,
fallbackDelayMs: 250,
fallbackFinalText: "fallback final",
transitionClearsRunTracking: true,
});
expect(noFallback.deferTransitionPatch).toBe(false);
expect(
noFallback.commands.find((command) => command.kind === "scheduleLifecycleFallback")
).toBeUndefined();
expect(noFallback.commands).toEqual(
expect.arrayContaining([
{ kind: "markRunClosed", runId: "run-2" },
{ kind: "clearRunTracking", runId: "run-2" },
])
);
});
it("supports closed-run mark, lookup, and prune semantics", () => {
let state = createRuntimeTerminalState();
state = applyTerminalCommit(state, {
runId: "run-closed",
source: "lifecycle-fallback",
seq: null,
});
state = markClosedRun(state, {
runId: "run-closed",
now: 1000,
ttlMs: 30,
});
expect(isClosedRun(state, "run-closed")).toBe(true);
const beforeExpiry = pruneClosedRuns(state, { at: 1029 });
expect(beforeExpiry.expiredRunIds).toEqual([]);
expect(isClosedRun(beforeExpiry.state, "run-closed")).toBe(true);
const afterExpiry = pruneClosedRuns(beforeExpiry.state, { at: 1030 });
expect(afterExpiry.expiredRunIds).toEqual(["run-closed"]);
expect(isClosedRun(afterExpiry.state, "run-closed")).toBe(false);
});
it("transitions commit source from lifecycle fallback to chat final", () => {
let state = createRuntimeTerminalState();
state = applyTerminalCommit(state, {
runId: "run-3",
source: "lifecycle-fallback",
seq: null,
});
const missingSeqAfterFallback = deriveChatTerminalDecision({
state,
runId: "run-3",
isFinal: true,
seq: null,
});
expect(missingSeqAfterFallback.isStaleTerminal).toBe(false);
expect(missingSeqAfterFallback.commitSourceBeforeFinal).toBe("lifecycle-fallback");
state = applyTerminalCommit(state, {
runId: "run-3",
source: "chat-final",
seq: 2,
});
const missingSeqAfterChatFinal = deriveChatTerminalDecision({
state,
runId: "run-3",
isFinal: true,
seq: null,
});
expect(missingSeqAfterChatFinal.isStaleTerminal).toBe(true);
expect(missingSeqAfterChatFinal.commitSourceBeforeFinal).toBe("chat-final");
});
it("generates fallback schedule intent with explicit delay and no timer handles", () => {
const decision = deriveLifecycleTerminalDecision({
mode: "event",
state: createRuntimeTerminalState(),
runId: "run-4",
phase: "end",
hasPendingFallbackTimer: false,
fallbackDelayMs: 777,
fallbackFinalText: "fallback",
transitionClearsRunTracking: true,
});
const schedule = decision.commands.find(
(command) => command.kind === "scheduleLifecycleFallback"
);
expect(schedule).toEqual({
kind: "scheduleLifecycleFallback",
runId: "run-4",
delayMs: 777,
finalText: "fallback",
});
});
});
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import {
buildSettingsRouteHref,
parseSettingsRouteAgentIdFromPathname,
planBackToChatCommands,
planFleetSelectCommands,
planNonRouteSelectionSyncCommands,
planOpenSettingsRouteCommands,
planSettingsRouteSyncCommands,
planSettingsTabChangeCommands,
} from "@/features/agents/operations/settingsRouteWorkflow";
describe("settingsRouteWorkflow", () => {
it("parses a valid settings route agent id", () => {
expect(parseSettingsRouteAgentIdFromPathname("/agents/agent%201/settings")).toBe("agent 1");
});
it("returns null for non-settings routes", () => {
expect(parseSettingsRouteAgentIdFromPathname("/agents/main/chat")).toBeNull();
expect(parseSettingsRouteAgentIdFromPathname("/")).toBeNull();
});
it("falls back to raw path segment when decoding throws", () => {
expect(parseSettingsRouteAgentIdFromPathname("/agents/%E0%A4%A/settings")).toBe(
"%E0%A4%A"
);
});
it("builds encoded settings route href", () => {
expect(buildSettingsRouteHref("agent one/2")).toBe("/agents/agent%20one%2F2/settings");
});
it("throws when building settings route href with empty agent id", () => {
expect(() => buildSettingsRouteHref(" ")).toThrow(
"Cannot build settings route href: agent id is empty."
);
});
it("requires discard confirmation for back-to-chat when personality is dirty", () => {
expect(
planBackToChatCommands({
settingsRouteActive: true,
activeTab: "personality",
personalityHasUnsavedChanges: true,
discardConfirmed: false,
})
).toEqual([]);
expect(
planBackToChatCommands({
settingsRouteActive: true,
activeTab: "personality",
personalityHasUnsavedChanges: true,
discardConfirmed: true,
})
).toEqual([
{ kind: "set-personality-dirty", value: false },
{ kind: "push", href: "/" },
]);
});
it("plans settings tab change and clears personality dirty state after confirmed discard", () => {
expect(
planSettingsTabChangeCommands({
nextTab: "capabilities",
currentInspectSidebar: { agentId: "agent-1", tab: "personality" },
settingsRouteAgentId: "agent-1",
settingsRouteActive: true,
personalityHasUnsavedChanges: true,
discardConfirmed: false,
})
).toEqual([]);
expect(
planSettingsTabChangeCommands({
nextTab: "capabilities",
currentInspectSidebar: { agentId: "agent-1", tab: "personality" },
settingsRouteAgentId: "agent-1",
settingsRouteActive: true,
personalityHasUnsavedChanges: true,
discardConfirmed: true,
})
).toEqual([
{ kind: "set-personality-dirty", value: false },
{
kind: "set-inspect-sidebar",
value: { agentId: "agent-1", tab: "capabilities" },
},
]);
});
it("plans route-agent synchronization commands", () => {
expect(
planSettingsRouteSyncCommands({
settingsRouteActive: true,
settingsRouteAgentId: "agent-2",
status: "connected",
agentsLoadedOnce: true,
selectedAgentId: "agent-1",
hasRouteAgent: true,
currentInspectSidebar: null,
})
).toEqual([
{
kind: "set-inspect-sidebar",
value: { agentId: "agent-2", tab: "personality" },
},
{ kind: "select-agent", agentId: "agent-2" },
]);
});
it("plans redirect when settings route agent is missing after load", () => {
expect(
planSettingsRouteSyncCommands({
settingsRouteActive: true,
settingsRouteAgentId: "missing",
status: "connected",
agentsLoadedOnce: true,
selectedAgentId: null,
hasRouteAgent: false,
currentInspectSidebar: null,
})
).toEqual([{ kind: "replace", href: "/" }]);
});
it("plans non-route selection reconciliation", () => {
expect(
planNonRouteSelectionSyncCommands({
settingsRouteActive: false,
selectedAgentId: "agent-2",
focusedAgentId: "agent-3",
hasSelectedAgentInAgents: false,
currentInspectSidebar: { agentId: "agent-1", tab: "automations" },
hasInspectSidebarAgent: false,
})
).toEqual([
{
kind: "set-inspect-sidebar",
value: { agentId: "agent-2", tab: "automations" },
},
{ kind: "set-inspect-sidebar", value: null },
{ kind: "select-agent", agentId: null },
{ kind: "select-agent", agentId: "agent-3" },
]);
});
it("plans settings-route open and fleet-select commands with draft flush", () => {
expect(
planOpenSettingsRouteCommands({
agentId: "agent 2",
currentInspectSidebar: { agentId: "agent-1", tab: "advanced" },
focusedAgentId: "agent-1",
})
).toEqual([
{ kind: "flush-pending-draft", agentId: "agent-1" },
{ kind: "select-agent", agentId: "agent 2" },
{
kind: "set-inspect-sidebar",
value: { agentId: "agent 2", tab: "advanced" },
},
{ kind: "set-mobile-pane-chat" },
{ kind: "push", href: "/agents/agent%202/settings" },
]);
expect(
planFleetSelectCommands({
agentId: "agent-9",
currentInspectSidebar: { agentId: "agent-1", tab: "capabilities" },
focusedAgentId: "agent-3",
})
).toEqual([
{ kind: "flush-pending-draft", agentId: "agent-3" },
{ kind: "select-agent", agentId: "agent-9" },
{
kind: "set-inspect-sidebar",
value: { agentId: "agent-9", tab: "capabilities" },
},
{ kind: "set-mobile-pane-chat" },
]);
});
});
+282
View File
@@ -0,0 +1,282 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentState, AgentStoreSeed } from "@/features/agents/state/store";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import type { StudioSettingsPatch } from "@/lib/studio/settings";
vi.mock("@/features/agents/operations/agentFleetHydration", () => ({
hydrateAgentFleetFromGateway: vi.fn(),
}));
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
import {
executeStudioBootstrapLoadCommands,
executeStudioFocusedPatchCommands,
executeStudioFocusedPreferenceLoadCommands,
runStudioBootstrapLoadOperation,
runStudioFocusFilterPersistenceOperation,
runStudioFocusedPreferenceLoadOperation,
runStudioFocusedSelectionPersistenceOperation,
type StudioBootstrapLoadCommand,
} from "@/features/agents/operations/studioBootstrapOperation";
const hydrateAgentFleetFromGatewayMock = vi.mocked(hydrateAgentFleetFromGateway);
describe("studioBootstrapOperation", () => {
beforeEach(() => {
hydrateAgentFleetFromGatewayMock.mockReset();
});
it("builds bootstrap commands from hydrated fleet result", async () => {
const seeds: AgentStoreSeed[] = [
{
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:main",
},
{
agentId: "agent-2",
name: "Agent Two",
sessionKey: "agent:agent-2:main",
},
];
const snapshot = { config: {} } as GatewayModelPolicySnapshot;
hydrateAgentFleetFromGatewayMock.mockResolvedValue({
seeds,
sessionCreatedAgentIds: ["agent-1"],
sessionSettingsSyncedAgentIds: ["agent-1"],
summaryPatches: [{ agentId: "agent-2", patch: { latestPreview: "hello" } }],
suggestedSelectedAgentId: "agent-2",
configSnapshot: snapshot,
});
const commands = await runStudioBootstrapLoadOperation({
client: { call: async () => null },
gatewayUrl: "https://gateway.test",
cachedConfigSnapshot: null,
loadStudioSettings: async () => null,
isDisconnectLikeError: () => false,
preferredSelectedAgentId: "agent-1",
hasCurrentSelection: false,
});
expect(commands).toEqual([
{ kind: "set-gateway-config-snapshot", snapshot },
{
kind: "hydrate-agents",
seeds,
initialSelectedAgentId: "agent-1",
},
{
kind: "mark-session-created",
agentId: "agent-1",
sessionSettingsSynced: true,
},
{
kind: "apply-summary-patch",
agentId: "agent-2",
patch: { latestPreview: "hello" },
},
]);
});
it("returns set-error command when fleet hydration fails", async () => {
hydrateAgentFleetFromGatewayMock.mockRejectedValue(new Error("load failed"));
const commands = await runStudioBootstrapLoadOperation({
client: { call: async () => null },
gatewayUrl: "https://gateway.test",
cachedConfigSnapshot: null,
loadStudioSettings: async () => null,
isDisconnectLikeError: () => false,
preferredSelectedAgentId: null,
hasCurrentSelection: false,
});
expect(commands).toEqual([{ kind: "set-error", message: "load failed" }]);
});
it("executes bootstrap commands with injected callbacks", () => {
const commands: StudioBootstrapLoadCommand[] = [
{
kind: "set-gateway-config-snapshot",
snapshot: { config: {} } as GatewayModelPolicySnapshot,
},
{
kind: "hydrate-agents",
seeds: [{ agentId: "agent-1", name: "Agent One", sessionKey: "s1" }],
initialSelectedAgentId: "agent-1",
},
{
kind: "mark-session-created",
agentId: "agent-1",
sessionSettingsSynced: true,
},
{
kind: "apply-summary-patch",
agentId: "agent-1",
patch: { latestPreview: "preview" },
},
{
kind: "set-error",
message: "failed",
},
];
const setGatewayConfigSnapshot = vi.fn();
const hydrateAgents = vi.fn();
const dispatchUpdateAgent = vi.fn();
const setError = vi.fn();
executeStudioBootstrapLoadCommands({
commands,
setGatewayConfigSnapshot,
hydrateAgents,
dispatchUpdateAgent,
setError,
});
expect(setGatewayConfigSnapshot).toHaveBeenCalledTimes(1);
expect(hydrateAgents).toHaveBeenCalledWith(
[{ agentId: "agent-1", name: "Agent One", sessionKey: "s1" }],
"agent-1"
);
expect(dispatchUpdateAgent).toHaveBeenCalledWith("agent-1", {
sessionCreated: true,
sessionSettingsSynced: true,
});
expect(dispatchUpdateAgent).toHaveBeenCalledWith("agent-1", { latestPreview: "preview" });
expect(setError).toHaveBeenCalledWith("failed");
});
it("loads focused preference and emits restore commands", async () => {
const commands = await runStudioFocusedPreferenceLoadOperation({
gatewayUrl: "https://gateway.test",
loadStudioSettings: async () => ({
version: 1,
gateway: null,
focused: {
"https://gateway.test": {
mode: "focused",
selectedAgentId: "agent-9",
filter: "running",
},
},
avatars: {},
}),
isFocusFilterTouched: () => false,
});
expect(commands).toEqual([
{
kind: "set-preferred-selected-agent-id",
agentId: "agent-9",
},
{
kind: "set-focus-filter",
filter: "running",
},
{
kind: "set-focused-preferences-loaded",
value: true,
},
]);
});
it("skips focused preference restore when user touched filter during load", async () => {
const commands = await runStudioFocusedPreferenceLoadOperation({
gatewayUrl: "https://gateway.test",
loadStudioSettings: async () => ({
version: 1,
gateway: null,
focused: {
"https://gateway.test": {
mode: "focused",
selectedAgentId: "agent-9",
filter: "running",
},
},
avatars: {},
}),
isFocusFilterTouched: () => true,
});
expect(commands).toEqual([
{
kind: "set-focused-preferences-loaded",
value: true,
},
]);
});
it("returns focused preference load error command on failure", async () => {
const commands = await runStudioFocusedPreferenceLoadOperation({
gatewayUrl: "https://gateway.test",
loadStudioSettings: async () => {
throw new Error("settings failed");
},
isFocusFilterTouched: () => false,
});
expect(commands[0]).toMatchObject({
kind: "log-error",
message: "Failed to load focused preference.",
});
expect(commands[1]).toEqual({
kind: "set-focused-preferences-loaded",
value: true,
});
});
it("executes focused preference load commands", () => {
const setFocusedPreferencesLoaded = vi.fn();
const setPreferredSelectedAgentId = vi.fn();
const setFocusFilter = vi.fn();
const logError = vi.fn();
executeStudioFocusedPreferenceLoadCommands({
commands: [
{ kind: "set-focused-preferences-loaded", value: false },
{ kind: "set-preferred-selected-agent-id", agentId: "agent-1" },
{ kind: "set-focus-filter", filter: "idle" },
{ kind: "log-error", message: "failed", error: new Error("boom") },
],
setFocusedPreferencesLoaded,
setPreferredSelectedAgentId,
setFocusFilter,
logError,
});
expect(setFocusedPreferencesLoaded).toHaveBeenCalledWith(false);
expect(setPreferredSelectedAgentId).toHaveBeenCalledWith("agent-1");
expect(setFocusFilter).toHaveBeenCalledWith("idle");
expect(logError).toHaveBeenCalledTimes(1);
});
it("plans focused persistence patch commands and executes scheduler", () => {
const filterCommands = runStudioFocusFilterPersistenceOperation({
gatewayUrl: "https://gateway.test",
focusFilterTouched: true,
focusFilter: "running",
});
const selectionCommands = runStudioFocusedSelectionPersistenceOperation({
gatewayUrl: "https://gateway.test",
status: "connected",
focusedPreferencesLoaded: true,
agentsLoadedOnce: true,
selectedAgentId: "agent-2",
});
const schedulePatch = vi.fn();
executeStudioFocusedPatchCommands({
commands: [...filterCommands, ...selectionCommands],
schedulePatch,
});
expect(schedulePatch).toHaveBeenCalledTimes(2);
const firstCall = schedulePatch.mock.calls[0] as [StudioSettingsPatch, number];
const secondCall = schedulePatch.mock.calls[1] as [StudioSettingsPatch, number];
expect(firstCall[1]).toBe(300);
expect(secondCall[1]).toBe(300);
});
});
+181
View File
@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import {
planBootstrapSelection,
planFocusedFilterPatch,
planFocusedPreferenceRestore,
planFocusedSelectionPatch,
} from "@/features/agents/operations/studioBootstrapWorkflow";
import type { StudioSettings } from "@/lib/studio/settings";
describe("studioBootstrapWorkflow", () => {
it("keeps existing selection when one is already active", () => {
const intent = planBootstrapSelection({
hasCurrentSelection: true,
preferredSelectedAgentId: "agent-1",
availableAgentIds: ["agent-1", "agent-2"],
suggestedSelectedAgentId: "agent-2",
});
expect(intent).toEqual({ initialSelectedAgentId: undefined });
});
it("prefers saved selected agent when present in seeds", () => {
const intent = planBootstrapSelection({
hasCurrentSelection: false,
preferredSelectedAgentId: "agent-2",
availableAgentIds: ["agent-1", "agent-2"],
suggestedSelectedAgentId: "agent-1",
});
expect(intent).toEqual({ initialSelectedAgentId: "agent-2" });
});
it("falls back to suggested selected agent when saved preference is unavailable", () => {
const intent = planBootstrapSelection({
hasCurrentSelection: false,
preferredSelectedAgentId: "agent-9",
availableAgentIds: ["agent-1", "agent-2"],
suggestedSelectedAgentId: "agent-1",
});
expect(intent).toEqual({ initialSelectedAgentId: "agent-1" });
});
it("builds focused filter patch only when gateway key and touch state allow it", () => {
expect(
planFocusedFilterPatch({
gatewayKey: "",
focusFilterTouched: true,
focusFilter: "running",
})
).toEqual({ kind: "skip", reason: "missing-gateway-key" });
expect(
planFocusedFilterPatch({
gatewayKey: "https://gateway.test",
focusFilterTouched: false,
focusFilter: "running",
})
).toEqual({ kind: "skip", reason: "focus-filter-not-touched" });
expect(
planFocusedFilterPatch({
gatewayKey: "https://gateway.test",
focusFilterTouched: true,
focusFilter: "running",
})
).toEqual({
kind: "patch",
patch: {
focused: {
"https://gateway.test": {
mode: "focused",
filter: "running",
},
},
},
debounceMs: 300,
});
});
it("builds focused selected-agent patch only when connection and load gates pass", () => {
expect(
planFocusedSelectionPatch({
gatewayKey: "",
status: "connected",
focusedPreferencesLoaded: true,
agentsLoadedOnce: true,
selectedAgentId: "agent-1",
})
).toEqual({ kind: "skip", reason: "missing-gateway-key" });
expect(
planFocusedSelectionPatch({
gatewayKey: "https://gateway.test",
status: "connecting",
focusedPreferencesLoaded: true,
agentsLoadedOnce: true,
selectedAgentId: "agent-1",
})
).toEqual({ kind: "skip", reason: "not-connected" });
expect(
planFocusedSelectionPatch({
gatewayKey: "https://gateway.test",
status: "connected",
focusedPreferencesLoaded: false,
agentsLoadedOnce: true,
selectedAgentId: "agent-1",
})
).toEqual({ kind: "skip", reason: "focused-preferences-not-loaded" });
expect(
planFocusedSelectionPatch({
gatewayKey: "https://gateway.test",
status: "connected",
focusedPreferencesLoaded: true,
agentsLoadedOnce: false,
selectedAgentId: "agent-1",
})
).toEqual({ kind: "skip", reason: "agents-not-loaded" });
expect(
planFocusedSelectionPatch({
gatewayKey: "https://gateway.test",
status: "connected",
focusedPreferencesLoaded: true,
agentsLoadedOnce: true,
selectedAgentId: "agent-2",
})
).toEqual({
kind: "patch",
patch: {
focused: {
"https://gateway.test": {
mode: "focused",
selectedAgentId: "agent-2",
},
},
},
debounceMs: 300,
});
});
it("resolves focused preference restore values from settings", () => {
const settings: StudioSettings = {
version: 1,
gateway: null,
focused: {
"https://gateway.test": {
mode: "focused",
selectedAgentId: "agent-3",
filter: "idle",
},
},
avatars: {},
};
expect(
planFocusedPreferenceRestore({
settings,
gatewayKey: "https://gateway.test",
focusFilterTouched: false,
})
).toEqual({
preferredSelectedAgentId: "agent-3",
focusFilter: "idle",
});
expect(
planFocusedPreferenceRestore({
settings,
gatewayKey: "https://gateway.unknown",
focusFilterTouched: false,
})
).toEqual({
preferredSelectedAgentId: null,
focusFilter: "all",
});
});
});
@@ -0,0 +1,372 @@
import { createElement, useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, waitFor } from "@testing-library/react";
import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation";
import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder";
import type { CronRunResult } from "@/lib/cron/types";
import type { MutationBlockState } from "@/features/agents/operations/mutationLifecycleWorkflow";
import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController";
import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation";
import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation";
import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation";
import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/mutationLifecycleWorkflow";
import { runCronJobNow, removeCronJob } from "@/lib/cron/types";
import { shouldAwaitDisconnectRestartForRemoteMutation } from "@/lib/gateway/gatewayReloadMode";
let restartBlockHookParams:
| {
block: MutationBlockState | null;
onTimeout: () => void;
onRestartComplete: (
block: MutationBlockState,
ctx: { isCancelled: () => boolean }
) => void | Promise<void>;
}
| null = null;
vi.mock("@/features/agents/operations/useGatewayRestartBlock", () => ({
useGatewayRestartBlock: (params: {
block: MutationBlockState | null;
onTimeout: () => void;
onRestartComplete: (
block: MutationBlockState,
ctx: { isCancelled: () => boolean }
) => void | Promise<void>;
}) => {
restartBlockHookParams = {
block: params.block,
onTimeout: params.onTimeout,
onRestartComplete: params.onRestartComplete,
};
},
}));
vi.mock("@/features/agents/operations/deleteAgentOperation", () => ({
deleteAgentViaStudio: vi.fn(),
}));
vi.mock("@/features/agents/operations/cronCreateOperation", () => ({
performCronCreateFlow: vi.fn(),
}));
vi.mock("@/features/agents/operations/agentPermissionsOperation", async () => {
const actual = await vi.importActual<
typeof import("@/features/agents/operations/agentPermissionsOperation")
>("@/features/agents/operations/agentPermissionsOperation");
return {
...actual,
updateAgentPermissionsViaStudio: vi.fn(),
};
});
vi.mock("@/features/agents/operations/mutationLifecycleWorkflow", async () => {
const actual = await vi.importActual<
typeof import("@/features/agents/operations/mutationLifecycleWorkflow")
>("@/features/agents/operations/mutationLifecycleWorkflow");
return {
...actual,
runAgentConfigMutationLifecycle: vi.fn(),
};
});
vi.mock("@/lib/cron/types", async () => {
const actual = await vi.importActual<typeof import("@/lib/cron/types")>("@/lib/cron/types");
return {
...actual,
runCronJobNow: vi.fn(),
removeCronJob: vi.fn(),
listCronJobs: vi.fn(async () => ({ jobs: [] })),
};
});
vi.mock("@/lib/gateway/gatewayReloadMode", () => ({
shouldAwaitDisconnectRestartForRemoteMutation: vi.fn(async () => false),
}));
type ControllerValue = ReturnType<typeof useAgentSettingsMutationController>;
const draft: AgentPermissionsDraft = {
commandMode: "ask",
webAccess: true,
fileTools: false,
};
const createCronDraft = (): CronCreateDraft => ({
templateId: "custom",
name: "Nightly sync",
taskText: "Sync project status.",
scheduleKind: "every",
everyAmount: 30,
everyUnit: "minutes",
deliveryMode: "announce",
deliveryChannel: "last",
});
const renderController = (overrides?: Partial<Parameters<typeof useAgentSettingsMutationController>[0]>) => {
const setError = vi.fn();
const clearInspectSidebar = vi.fn();
const setInspectSidebarCapabilities = vi.fn();
const dispatchUpdateAgent = vi.fn();
const setMobilePaneChat = vi.fn();
const loadAgents = vi.fn(async () => undefined);
const refreshGatewayConfigSnapshot = vi.fn(async () => null);
const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise<void> }) => {
await run();
});
const client = {
call: vi.fn(async () => ({})),
};
const params: Parameters<typeof useAgentSettingsMutationController>[0] = {
client: client as never,
status: "connected",
isLocalGateway: false,
agents: [{ agentId: "agent-1", name: "Agent One", sessionKey: "session-1" }] as never,
hasCreateBlock: false,
enqueueConfigMutation,
gatewayConfigSnapshot: null,
settingsRouteActive: false,
inspectSidebarAgentId: null,
inspectSidebarTab: null,
loadAgents,
refreshGatewayConfigSnapshot,
clearInspectSidebar,
setInspectSidebarCapabilities,
dispatchUpdateAgent,
setMobilePaneChat,
setError,
...(overrides ?? {}),
};
const valueRef: { current: ControllerValue | null } = { current: null };
const Probe = ({ onValue }: { onValue: (next: ControllerValue) => void }) => {
const value = useAgentSettingsMutationController(params);
useEffect(() => {
onValue(value);
}, [onValue, value]);
return createElement("div", { "data-testid": "probe" }, "ok");
};
render(
createElement(Probe, {
onValue: (next) => {
valueRef.current = next;
},
})
);
return {
getValue: () => {
if (!valueRef.current) throw new Error("hook value unavailable");
return valueRef.current;
},
setError,
clearInspectSidebar,
setInspectSidebarCapabilities,
dispatchUpdateAgent,
setMobilePaneChat,
loadAgents,
refreshGatewayConfigSnapshot,
enqueueConfigMutation,
};
};
describe("useAgentSettingsMutationController", () => {
const mockedDeleteAgentViaStudio = vi.mocked(deleteAgentViaStudio);
const mockedPerformCronCreateFlow = vi.mocked(performCronCreateFlow);
const mockedRunCronJobNow = vi.mocked(runCronJobNow);
const mockedRemoveCronJob = vi.mocked(removeCronJob);
const mockedRunLifecycle = vi.mocked(runAgentConfigMutationLifecycle);
const mockedUpdateAgentPermissions = vi.mocked(updateAgentPermissionsViaStudio);
const mockedShouldAwaitRemoteRestart = vi.mocked(shouldAwaitDisconnectRestartForRemoteMutation);
beforeEach(() => {
restartBlockHookParams = null;
mockedDeleteAgentViaStudio.mockReset();
mockedPerformCronCreateFlow.mockReset();
mockedRunCronJobNow.mockReset();
mockedRemoveCronJob.mockReset();
mockedRunLifecycle.mockReset();
mockedUpdateAgentPermissions.mockReset();
mockedShouldAwaitRemoteRestart.mockReset();
mockedShouldAwaitRemoteRestart.mockResolvedValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("delete_denied_by_guard_does_not_run_delete_side_effect", async () => {
const ctx = renderController({ status: "disconnected" });
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
});
it("delete_cancelled_by_confirmation_does_not_run_delete_side_effect", async () => {
vi.spyOn(window, "confirm").mockReturnValue(false);
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
});
it("reserved_main_delete_sets_error_and_skips_enqueue", async () => {
const ctx = renderController({
agents: [{ agentId: "main", name: "Main", sessionKey: "main-session" }] as never,
});
await act(async () => {
await ctx.getValue().handleDeleteAgent("main");
});
expect(ctx.setError).toHaveBeenCalledWith("The main agent cannot be deleted.");
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
});
it("cron_delete_is_denied_while_run_busy_without_changing_error_state", async () => {
mockedRunCronJobNow.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return { ok: true, ran: true } satisfies CronRunResult;
});
const ctx = renderController();
await act(async () => {
void ctx.getValue().handleRunCronJob("agent-1", "job-running");
});
await waitFor(() => {
expect(ctx.getValue().cronRunBusyJobId).toBe("job-running");
});
await act(async () => {
await ctx.getValue().handleDeleteCronJob("agent-1", "job-delete");
});
expect(mockedRemoveCronJob).not.toHaveBeenCalled();
expect(ctx.getValue().settingsCronError).toBeNull();
});
it("allowed_rename_and_delete_delegate_to_lifecycle_runner", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.setMutatingBlock();
await deps.executeMutation();
deps.clearBlock();
return true;
});
mockedDeleteAgentViaStudio.mockResolvedValue({ trashed: { trashDir: "", moved: [] }, restored: null });
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleRenameAgent("agent-1", "Renamed");
});
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(mockedRunLifecycle).toHaveBeenCalledTimes(2);
expect(mockedDeleteAgentViaStudio).toHaveBeenCalledTimes(1);
});
it("permissions_update_keeps_load_refresh_and_focus_side_effects", async () => {
mockedUpdateAgentPermissions.mockResolvedValue(undefined);
const callOrder: string[] = [];
const ctx = renderController({
loadAgents: vi.fn(async () => {
callOrder.push("loadAgents");
}),
refreshGatewayConfigSnapshot: vi.fn(async () => {
callOrder.push("refresh");
return null;
}),
});
await act(async () => {
await ctx.getValue().handleUpdateAgentPermissions("agent-1", draft);
});
expect(mockedUpdateAgentPermissions).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
sessionKey: "session-1",
draft,
})
);
expect(callOrder).toEqual(["loadAgents", "refresh"]);
expect(ctx.setInspectSidebarCapabilities).toHaveBeenCalledWith("agent-1");
expect(ctx.setMobilePaneChat).toHaveBeenCalled();
});
it("exposes_restart_block_state_and_timeout_completion_handlers", async () => {
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.patchBlockAwaitingRestart({ phase: "awaiting-restart", sawDisconnect: false });
return true;
});
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleRenameAgent("agent-1", "Renamed");
});
await waitFor(() => {
expect(ctx.getValue().hasRenameMutationBlock).toBe(true);
expect(ctx.getValue().restartingMutationBlock?.phase).toBe("awaiting-restart");
expect(ctx.getValue().hasRestartBlockInProgress).toBe(true);
});
expect(restartBlockHookParams?.block).not.toBeNull();
await act(async () => {
restartBlockHookParams?.onTimeout();
});
expect(ctx.setError).toHaveBeenCalledWith("Gateway restart timed out after renaming the agent.");
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.patchBlockAwaitingRestart({ phase: "awaiting-restart", sawDisconnect: false });
return true;
});
await act(async () => {
await ctx.getValue().handleRenameAgent("agent-1", "Renamed Again");
});
await waitFor(() => {
expect(restartBlockHookParams?.block?.phase).toBe("awaiting-restart");
});
await act(async () => {
await restartBlockHookParams?.onRestartComplete(
restartBlockHookParams.block as MutationBlockState,
{ isCancelled: () => false }
);
});
expect(ctx.loadAgents).toHaveBeenCalled();
expect(ctx.setMobilePaneChat).toHaveBeenCalled();
await waitFor(() => {
expect(ctx.getValue().restartingMutationBlock).toBeNull();
});
});
it("create_cron_handler_delegates_to_create_operation", async () => {
mockedPerformCronCreateFlow.mockResolvedValue("created");
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleCreateCronJob("agent-1", createCronDraft());
});
expect(mockedPerformCronCreateFlow).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,462 @@
import { createElement, useEffect } from "react";
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
import type { AgentState } from "@/features/agents/state/store";
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
vi.mock("@/features/agents/operations/chatSendOperation", () => ({
sendChatMessageViaStudio: vi.fn(async () => undefined),
}));
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
const base: AgentState = {
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:studio:test-session",
status: "idle",
sessionCreated: true,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: "run-1",
runStartedAt: null,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: true,
model: "openai/gpt-5",
thinkingLevel: "medium",
avatarSeed: "seed-1",
avatarUrl: null,
};
const merged = { ...base, ...(overrides ?? {}) };
return {
...merged,
historyFetchLimit: merged.historyFetchLimit ?? null,
historyFetchedCount: merged.historyFetchedCount ?? null,
historyMaybeTruncated: merged.historyMaybeTruncated ?? false,
};
};
type ControllerValue = ReturnType<typeof useChatInteractionController>;
type GatewayStatus = "disconnected" | "connecting" | "connected";
type InteractionDispatchAction =
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
| { type: "appendOutput"; agentId: string; line: string };
type CallFn = (method: string, params: unknown) => Promise<unknown>;
type DispatchFn = (action: InteractionDispatchAction) => void;
type ErrorFn = (message: string) => void;
type RunTrackingFn = (runId?: string | null) => void;
type HistoryInFlightFn = (sessionKey: string) => void;
type AgentIdFn = (agentId: string) => void;
type VoidFn = () => void;
type RenderControllerContext = {
getValue: () => ControllerValue;
unmount: () => void;
setAgents: (next: AgentState[]) => void;
call: ReturnType<typeof vi.fn<CallFn>>;
dispatch: ReturnType<typeof vi.fn<DispatchFn>>;
setError: ReturnType<typeof vi.fn<ErrorFn>>;
clearRunTracking: ReturnType<typeof vi.fn<RunTrackingFn>>;
clearHistoryInFlight: ReturnType<typeof vi.fn<HistoryInFlightFn>>;
clearSpecialUpdateMarker: ReturnType<typeof vi.fn<AgentIdFn>>;
clearSpecialLatestUpdateInFlight: ReturnType<typeof vi.fn<AgentIdFn>>;
setInspectSidebarNull: ReturnType<typeof vi.fn<VoidFn>>;
setMobilePaneChat: ReturnType<typeof vi.fn<VoidFn>>;
};
const renderController = (
overrides?: Partial<{
status: GatewayStatus;
agents: AgentState[];
call: CallFn;
dispatch: DispatchFn;
setError: ErrorFn;
clearRunTracking: RunTrackingFn;
clearHistoryInFlight: HistoryInFlightFn;
clearSpecialUpdateMarker: AgentIdFn;
clearSpecialLatestUpdateInFlight: AgentIdFn;
setInspectSidebarNull: VoidFn;
setMobilePaneChat: VoidFn;
}>
): RenderControllerContext => {
let agents = overrides?.agents ?? [createAgent()];
const call = vi.fn<CallFn>(overrides?.call ?? (async () => ({})));
const dispatch = vi.fn<DispatchFn>(overrides?.dispatch ?? (() => undefined));
const setError = vi.fn<ErrorFn>(overrides?.setError ?? (() => undefined));
const clearRunTracking = vi.fn<RunTrackingFn>(
overrides?.clearRunTracking ?? (() => undefined)
);
const clearHistoryInFlight = vi.fn<HistoryInFlightFn>(
overrides?.clearHistoryInFlight ?? (() => undefined)
);
const clearSpecialUpdateMarker = vi.fn<AgentIdFn>(
overrides?.clearSpecialUpdateMarker ?? (() => undefined)
);
const clearSpecialLatestUpdateInFlight = vi.fn<AgentIdFn>(
overrides?.clearSpecialLatestUpdateInFlight ?? (() => undefined)
);
const setInspectSidebarNull = vi.fn<VoidFn>(
overrides?.setInspectSidebarNull ?? (() => undefined)
);
const setMobilePaneChat = vi.fn<VoidFn>(overrides?.setMobilePaneChat ?? (() => undefined));
const valueRef: { current: ControllerValue | null } = { current: null };
const Probe = ({
onValue,
}: {
onValue: (value: ControllerValue) => void;
}) => {
const value = useChatInteractionController({
client: {
call,
},
status: overrides?.status ?? "connected",
dispatch,
setError,
getAgents: () => agents,
clearRunTracking,
clearHistoryInFlight,
clearSpecialUpdateMarker,
clearSpecialLatestUpdateInFlight,
setInspectSidebarNull,
setMobilePaneChat,
});
useEffect(() => {
onValue(value);
}, [onValue, value]);
return createElement("div", { "data-testid": "probe" }, "ok");
};
const rendered = render(
createElement(Probe, {
onValue: (value) => {
valueRef.current = value;
},
})
);
return {
getValue: () => {
if (!valueRef.current) throw new Error("controller value unavailable");
return valueRef.current;
},
unmount: () => {
rendered.unmount();
},
setAgents: (next) => {
agents = next;
rendered.rerender(
createElement(Probe, {
onValue: (value) => {
valueRef.current = value;
},
})
);
},
call,
dispatch,
setError,
clearRunTracking,
clearHistoryInFlight,
clearSpecialUpdateMarker,
clearSpecialLatestUpdateInFlight,
setInspectSidebarNull,
setMobilePaneChat,
};
};
describe("useChatInteractionController", () => {
const mockedSendChatMessageViaStudio = vi.mocked(sendChatMessageViaStudio);
const originalRaf = globalThis.requestAnimationFrame;
const originalCaf = globalThis.cancelAnimationFrame;
beforeEach(() => {
vi.useFakeTimers();
mockedSendChatMessageViaStudio.mockReset();
mockedSendChatMessageViaStudio.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCaf;
vi.restoreAllMocks();
});
it("flushes pending draft and cancels debounce timer", async () => {
const ctx = renderController();
act(() => {
ctx.getValue().handleDraftChange("agent-1", "first");
ctx.getValue().handleDraftChange("agent-1", "second");
});
expect(ctx.dispatch).not.toHaveBeenCalled();
act(() => {
ctx.getValue().flushPendingDraft("agent-1");
});
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "updateAgent",
agentId: "agent-1",
patch: { draft: "second" },
});
await vi.advanceTimersByTimeAsync(1000);
const draftUpdates = ctx.dispatch.mock.calls
.map(([action]: [InteractionDispatchAction]) => action)
.filter(
(action) =>
action.type === "updateAgent" &&
action.agentId === "agent-1" &&
action.patch?.draft === "second"
);
expect(draftUpdates).toHaveLength(1);
});
it("clears pending draft timer/value and live patch before send", async () => {
let queuedFrame: ((time: number) => void) | null = null;
globalThis.requestAnimationFrame = vi.fn((callback: (time: number) => void) => {
queuedFrame = callback;
return 77;
});
globalThis.cancelAnimationFrame = vi.fn();
const ctx = renderController();
act(() => {
ctx.getValue().handleDraftChange("agent-1", "queued draft");
ctx.getValue().queueLivePatch("agent-1", { streamText: "pending stream" });
});
await act(async () => {
await ctx.getValue().handleSend("agent-1", "session-1", " hello world ");
});
expect(mockedSendChatMessageViaStudio).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
sessionKey: "session-1",
message: "hello world",
})
);
expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(77);
await vi.advanceTimersByTimeAsync(300);
expect(
ctx.dispatch.mock.calls.some(
([action]: [InteractionDispatchAction]) =>
action.type === "updateAgent" &&
action.agentId === "agent-1" &&
action.patch?.draft === "queued draft"
)
).toBe(false);
if (queuedFrame) {
act(() => {
queuedFrame?.(0);
});
}
expect(
ctx.dispatch.mock.calls.some(
([action]: [InteractionDispatchAction]) =>
action.type === "updateAgent" &&
action.agentId === "agent-1" &&
action.patch?.streamText === "pending stream"
)
).toBe(false);
});
it("deduplicates stop-run while busy and clears busy state after success", async () => {
let resolveAbort: ((value?: void | PromiseLike<void>) => void) | undefined;
const abortPromise = new Promise<void>((resolve) => {
resolveAbort = resolve;
});
const call = vi.fn(async (method: string) => {
if (method === "chat.abort") {
await abortPromise;
return {};
}
return {};
});
const ctx = renderController({ call });
let firstCall: Promise<void> | null = null;
act(() => {
firstCall = ctx.getValue().handleStopRun("agent-1", " session-1 ");
});
expect(ctx.getValue().stopBusyAgentId).toBe("agent-1");
await act(async () => {
await ctx.getValue().handleStopRun("agent-1", "session-1");
});
expect(call).toHaveBeenCalledTimes(1);
resolveAbort?.();
await act(async () => {
await firstCall;
});
expect(ctx.getValue().stopBusyAgentId).toBeNull();
});
it("reports stop-run failures and clears busy state", async () => {
const logSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const ctx = renderController({
call: vi.fn(async (method: string) => {
if (method === "chat.abort") {
throw new Error("abort failed");
}
return {};
}),
});
await act(async () => {
await ctx.getValue().handleStopRun("agent-1", "session-1");
});
expect(ctx.setError).toHaveBeenCalledWith("abort failed");
expect(logSpy).toHaveBeenCalledWith("abort failed");
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "appendOutput",
agentId: "agent-1",
line: "Stop failed: abort failed",
});
expect(ctx.getValue().stopBusyAgentId).toBeNull();
});
it("runs new-session side effects in sequence and updates agent state", async () => {
const order: string[] = [];
const call = vi.fn(async (method: string) => {
if (method === "sessions.reset") {
order.push("sessions.reset");
}
return {};
});
const dispatch = vi.fn<DispatchFn>((action) => {
if (action.type === "updateAgent") {
order.push("dispatch:updateAgent");
}
});
const clearRunTracking = vi.fn(() => {
order.push("clearRunTracking");
});
const clearHistoryInFlight = vi.fn(() => {
order.push("clearHistoryInFlight");
});
const clearSpecialUpdateMarker = vi.fn(() => {
order.push("clearSpecialUpdateMarker");
});
const clearSpecialLatestUpdateInFlight = vi.fn(() => {
order.push("clearSpecialLatestUpdateInFlight");
});
const setInspectSidebarNull = vi.fn(() => {
order.push("setInspectSidebarNull");
});
const setMobilePaneChat = vi.fn(() => {
order.push("setMobilePaneChat");
});
const ctx = renderController({
call,
dispatch,
clearRunTracking,
clearHistoryInFlight,
clearSpecialUpdateMarker,
clearSpecialLatestUpdateInFlight,
setInspectSidebarNull,
setMobilePaneChat,
agents: [
createAgent({
agentId: "agent-1",
runId: "run-42",
sessionKey: " session-42 ",
}),
],
});
await act(async () => {
await ctx.getValue().handleNewSession("agent-1");
});
expect(call).toHaveBeenCalledWith("sessions.reset", { key: "session-42" });
expect(order).toEqual([
"sessions.reset",
"clearRunTracking",
"clearHistoryInFlight",
"clearSpecialUpdateMarker",
"clearSpecialLatestUpdateInFlight",
"dispatch:updateAgent",
"setInspectSidebarNull",
"setMobilePaneChat",
]);
});
it("appends output when new-session fails", async () => {
const ctx = renderController({
agents: [
createAgent({
agentId: "agent-1",
sessionKey: " ",
}),
],
});
await act(async () => {
await ctx.getValue().handleNewSession("agent-1");
});
expect(ctx.setError).toHaveBeenCalledWith("Missing session key for agent.");
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "appendOutput",
agentId: "agent-1",
line: "New session failed: Missing session key for agent.",
});
});
it("cleans up draft timers and queued frame on unmount", async () => {
globalThis.requestAnimationFrame = vi.fn(() => 555);
globalThis.cancelAnimationFrame = vi.fn();
const clearTimeoutSpy = vi.spyOn(window, "clearTimeout");
const ctx = renderController();
act(() => {
ctx.getValue().handleDraftChange("agent-1", "queued");
ctx.getValue().queueLivePatch("agent-1", { streamText: "delta" });
});
ctx.unmount();
expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(555);
expect(clearTimeoutSpy).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(
ctx.dispatch.mock.calls.some(
([action]: [InteractionDispatchAction]) =>
action.type === "updateAgent" &&
action.agentId === "agent-1" &&
action.patch?.draft === "queued"
)
).toBe(false);
});
});
@@ -0,0 +1,372 @@
import { createElement, useEffect, useState } from "react";
import { act, render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useGatewayConfigSyncController } from "@/features/agents/operations/useGatewayConfigSyncController";
import type { GatewayModelChoice, GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
vi.mock("@/lib/gateway/agentConfig", async () => {
const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>(
"@/lib/gateway/agentConfig"
);
return {
...actual,
updateGatewayAgentOverrides: vi.fn(async () => undefined),
};
});
type ProbeValue = {
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
gatewayModels: GatewayModelChoice[];
gatewayModelsError: string | null;
refreshGatewayConfigSnapshot: () => Promise<GatewayModelPolicySnapshot | null>;
};
type RenderControllerContext = {
getValue: () => ProbeValue;
rerenderWith: (
overrides: Partial<{
status: "disconnected" | "connecting" | "connected";
settingsRouteActive: boolean;
inspectSidebarAgentId: string | null;
logError: (message: string, err: unknown) => void;
}>
) => void;
call: ReturnType<typeof vi.fn>;
enqueueConfigMutation: ReturnType<typeof vi.fn>;
loadAgents: ReturnType<typeof vi.fn>;
logError: (message: string, err: unknown) => void;
};
const countMethodCalls = (callMock: ReturnType<typeof vi.fn>, method: string) => {
return callMock.mock.calls.filter(([calledMethod]) => calledMethod === method).length;
};
type RenderControllerParams = {
status: "disconnected" | "connecting" | "connected";
settingsRouteActive: boolean;
inspectSidebarAgentId: string | null;
initialGatewayConfigSnapshot?: GatewayModelPolicySnapshot | null;
isDisconnectLikeError: (err: unknown) => boolean;
logError: (message: string, err: unknown) => void;
};
const renderController = (
overrides?: Partial<
RenderControllerParams & {
call: ReturnType<typeof vi.fn>;
enqueueConfigMutation: ReturnType<typeof vi.fn>;
loadAgents: ReturnType<typeof vi.fn>;
}
>
): RenderControllerContext => {
const call =
overrides?.call ??
vi.fn(async (method: string) => {
if (method === "config.get") {
return { config: {} };
}
if (method === "models.list") {
return { models: [] };
}
throw new Error(`Unhandled method: ${method}`);
});
const enqueueConfigMutation =
overrides?.enqueueConfigMutation ??
vi.fn(async ({ run }: { run: () => Promise<void> }) => {
await run();
});
const loadAgents = overrides?.loadAgents ?? vi.fn(async () => undefined);
const logError = (overrides?.logError ?? vi.fn()) as (message: string, err: unknown) => void;
let currentParams: RenderControllerParams = {
status: "connected" as const,
settingsRouteActive: false,
inspectSidebarAgentId: null as string | null,
isDisconnectLikeError: overrides?.isDisconnectLikeError ?? (() => false),
logError,
...overrides,
};
const valueRef: { current: ProbeValue | null } = { current: null };
const Probe = ({
params,
onValue,
}: {
params: typeof currentParams;
onValue: (value: ProbeValue) => void;
}) => {
const [client] = useState(() => ({ call }));
const [gatewayConfigSnapshot, setGatewayConfigSnapshot] = useState<GatewayModelPolicySnapshot | null>(
params.initialGatewayConfigSnapshot ?? null
);
const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]);
const [gatewayModelsError, setGatewayModelsError] = useState<string | null>(null);
const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({
client: client as unknown as GatewayClient,
status: params.status,
settingsRouteActive: params.settingsRouteActive,
inspectSidebarAgentId: params.inspectSidebarAgentId,
gatewayConfigSnapshot,
setGatewayConfigSnapshot,
setGatewayModels,
setGatewayModelsError,
enqueueConfigMutation: enqueueConfigMutation as (params: {
kind: "repair-sandbox-tool-allowlist";
label: string;
run: () => Promise<void>;
}) => Promise<void>,
loadAgents: loadAgents as () => Promise<void>,
isDisconnectLikeError: params.isDisconnectLikeError,
logError: params.logError,
});
useEffect(() => {
onValue({
gatewayConfigSnapshot,
gatewayModels,
gatewayModelsError,
refreshGatewayConfigSnapshot,
});
}, [gatewayConfigSnapshot, gatewayModels, gatewayModelsError, onValue, refreshGatewayConfigSnapshot]);
return createElement("div", { "data-testid": "probe" }, "ok");
};
const rendered = render(
createElement(Probe, {
params: currentParams,
onValue: (value) => {
valueRef.current = value;
},
})
);
return {
getValue: () => {
if (!valueRef.current) throw new Error("controller value unavailable");
return valueRef.current;
},
rerenderWith: (nextOverrides) => {
currentParams = {
...currentParams,
...nextOverrides,
};
rendered.rerender(
createElement(Probe, {
params: currentParams,
onValue: (value) => {
valueRef.current = value;
},
})
);
},
call,
enqueueConfigMutation,
loadAgents,
logError,
};
};
describe("useGatewayConfigSyncController", () => {
const mockedUpdateGatewayAgentOverrides = vi.mocked(updateGatewayAgentOverrides);
beforeEach(() => {
mockedUpdateGatewayAgentOverrides.mockReset();
mockedUpdateGatewayAgentOverrides.mockResolvedValue();
});
it("clears models, model error, and snapshot when disconnected", async () => {
const call = vi.fn(async (method: string) => {
if (method === "config.get") {
return { config: { agents: { list: [] } } };
}
if (method === "models.list") {
return { models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }] };
}
throw new Error(`Unhandled method: ${method}`);
});
const ctx = renderController({ call, status: "connected" });
await waitFor(() => {
expect(ctx.getValue().gatewayModels).toEqual([
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
]);
});
ctx.rerenderWith({ status: "disconnected" });
await waitFor(() => {
expect(ctx.getValue().gatewayModels).toEqual([]);
expect(ctx.getValue().gatewayModelsError).toBeNull();
expect(ctx.getValue().gatewayConfigSnapshot).toBeNull();
});
});
it("still loads models when config.get fails", async () => {
const call = vi.fn(async (method: string) => {
if (method === "config.get") {
throw new Error("config failed");
}
if (method === "models.list") {
return {
models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
};
}
throw new Error(`Unhandled method: ${method}`);
});
const logError = vi.fn();
const ctx = renderController({ call, logError });
await waitFor(() => {
expect(ctx.getValue().gatewayModels).toEqual([
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
]);
});
expect(countMethodCalls(call, "models.list")).toBe(1);
expect(logError).toHaveBeenCalledWith("Failed to load gateway config.", expect.any(Error));
});
it("captures model loading errors and clears models", async () => {
const call = vi.fn(async (method: string) => {
if (method === "config.get") {
return { config: { agents: { list: [] } } };
}
if (method === "models.list") {
throw new Error("models unavailable");
}
throw new Error(`Unhandled method: ${method}`);
});
const logError = vi.fn();
const ctx = renderController({ call, logError });
await waitFor(() => {
expect(ctx.getValue().gatewayModels).toEqual([]);
expect(ctx.getValue().gatewayModelsError).toBe("models unavailable");
});
expect(logError).toHaveBeenCalledWith("Failed to load gateway models.", expect.any(Error));
});
it("runs settings-route refresh only when inspect agent id is present", async () => {
const call = vi.fn(async (method: string) => {
if (method === "config.get") {
return { config: { agents: { list: [] } } };
}
if (method === "models.list") {
return { models: [] };
}
throw new Error(`Unhandled method: ${method}`);
});
renderController({
call,
status: "connected",
settingsRouteActive: true,
inspectSidebarAgentId: null,
});
await waitFor(() => {
expect(countMethodCalls(call, "config.get")).toBe(1);
expect(countMethodCalls(call, "models.list")).toBe(1);
});
const callEligible = vi.fn(async (method: string) => {
if (method === "config.get") {
return { config: { agents: { list: [] } } };
}
if (method === "models.list") {
return { models: [] };
}
throw new Error(`Unhandled method: ${method}`);
});
renderController({
call: callEligible,
status: "connected",
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
});
await waitFor(() => {
expect(countMethodCalls(callEligible, "config.get")).toBeGreaterThanOrEqual(2);
expect(countMethodCalls(callEligible, "models.list")).toBe(1);
});
});
it("enqueues sandbox repair once for eligible agents", async () => {
const brokenSnapshot = {
config: {
agents: {
list: [
{
id: "agent-broken",
sandbox: { mode: "all" },
tools: {
sandbox: {
tools: {
allow: [],
},
},
},
},
],
},
},
} as unknown as GatewayModelPolicySnapshot;
const call = vi.fn(async (method: string) => {
if (method === "config.get") {
return brokenSnapshot;
}
if (method === "models.list") {
return { models: [] };
}
throw new Error(`Unhandled method: ${method}`);
});
const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise<void> }) => {
await run();
});
const loadAgents = vi.fn(async () => undefined);
const ctx = renderController({
call,
enqueueConfigMutation,
loadAgents,
initialGatewayConfigSnapshot: brokenSnapshot,
});
await waitFor(() => {
expect(enqueueConfigMutation).toHaveBeenCalledTimes(1);
expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1);
expect(loadAgents).toHaveBeenCalledTimes(1);
});
ctx.rerenderWith({ status: "connected" });
await act(async () => {
await Promise.resolve();
});
expect(enqueueConfigMutation).toHaveBeenCalledTimes(1);
expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1);
});
it("returns null when refresh is called while disconnected", async () => {
const call = vi.fn(async () => {
throw new Error("should not call gateway when disconnected");
});
const ctx = renderController({ call, status: "disconnected" });
const result = await ctx.getValue().refreshGatewayConfigSnapshot();
expect(result).toBeNull();
expect(call).not.toHaveBeenCalled();
});
});
+368
View File
@@ -0,0 +1,368 @@
import { createElement, useEffect } from "react";
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRuntimeSyncController } from "@/features/agents/operations/useRuntimeSyncController";
import type { AgentState } from "@/features/agents/state/store";
import {
executeAgentReconcileCommands,
runAgentReconcileOperation,
} from "@/features/agents/operations/agentReconcileOperation";
import {
executeHistorySyncCommands,
runHistorySyncOperation,
} from "@/features/agents/operations/historySyncOperation";
import type { GatewayGapInfo } from "@/lib/gateway/GatewayClient";
vi.mock("@/features/agents/operations/historySyncOperation", () => ({
runHistorySyncOperation: vi.fn(async () => []),
executeHistorySyncCommands: vi.fn(),
}));
vi.mock("@/features/agents/operations/agentReconcileOperation", () => ({
runAgentReconcileOperation: vi.fn(async () => []),
executeAgentReconcileCommands: vi.fn(),
}));
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:main",
status: "idle",
sessionCreated: true,
awaitingUserInput: false,
hasUnseenActivity: false,
outputLines: [],
lastResult: null,
lastDiff: null,
runId: null,
runStartedAt: null,
streamText: null,
thinkingTrace: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
sessionSettingsSynced: true,
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
toolCallingEnabled: true,
showThinkingTraces: true,
model: "openai/gpt-5",
thinkingLevel: "medium",
avatarSeed: "seed-1",
avatarUrl: null,
...(overrides ?? {}),
});
type RuntimeSyncControllerValue = ReturnType<typeof useRuntimeSyncController>;
type RenderControllerContext = {
getValue: () => RuntimeSyncControllerValue;
rerenderWith: (
overrides: Partial<Parameters<typeof useRuntimeSyncController>[0]>
) => void;
unmount: () => void;
dispatch: ReturnType<typeof vi.fn>;
clearRunTracking: ReturnType<typeof vi.fn>;
call: ReturnType<typeof vi.fn>;
onGap: ReturnType<typeof vi.fn>;
getGapHandler: () => ((info: GatewayGapInfo) => void) | null;
unsubscribeGap: ReturnType<typeof vi.fn>;
};
const renderController = (
overrides?: Partial<Parameters<typeof useRuntimeSyncController>[0]>
): RenderControllerContext => {
const dispatch = vi.fn();
const clearRunTracking = vi.fn();
const call = vi.fn(async (method: string) => {
if (method === "status") {
return { sessions: { recent: [], byAgent: [] } };
}
if (method === "sessions.preview") {
return { ts: 123, previews: [] };
}
return {};
});
let gapHandler: ((info: GatewayGapInfo) => void) | null = null;
const unsubscribeGap = vi.fn();
const onGap = vi.fn((handler: (info: GatewayGapInfo) => void) => {
gapHandler = handler;
return unsubscribeGap;
});
let currentParams: Parameters<typeof useRuntimeSyncController>[0] = {
client: {
call,
onGap,
} as never,
status: "connected",
agents: [createAgent({ status: "running", historyLoadedAt: 1000, runId: "run-1" })],
focusedAgentId: null,
focusedAgentRunning: false,
dispatch,
clearRunTracking,
isDisconnectLikeError: () => false,
defaultHistoryLimit: 200,
maxHistoryLimit: 5000,
...(overrides ?? {}),
};
const valueRef: { current: RuntimeSyncControllerValue | null } = { current: null };
const Probe = ({
params,
onValue,
}: {
params: Parameters<typeof useRuntimeSyncController>[0];
onValue: (value: RuntimeSyncControllerValue) => void;
}) => {
const value = useRuntimeSyncController(params);
useEffect(() => {
onValue(value);
}, [onValue, value]);
return createElement("div", { "data-testid": "probe" }, "ok");
};
const rendered = render(
createElement(Probe, {
params: currentParams,
onValue: (value) => {
valueRef.current = value;
},
})
);
return {
getValue: () => {
if (!valueRef.current) {
throw new Error("runtime sync controller value unavailable");
}
return valueRef.current;
},
rerenderWith: (nextOverrides) => {
currentParams = {
...currentParams,
...nextOverrides,
};
rendered.rerender(
createElement(Probe, {
params: currentParams,
onValue: (value) => {
valueRef.current = value;
},
})
);
},
unmount: () => {
rendered.unmount();
},
dispatch,
clearRunTracking,
call,
onGap,
getGapHandler: () => gapHandler,
unsubscribeGap,
};
};
describe("useRuntimeSyncController", () => {
const mockedRunHistorySyncOperation = vi.mocked(runHistorySyncOperation);
const mockedExecuteHistorySyncCommands = vi.mocked(executeHistorySyncCommands);
const mockedRunAgentReconcileOperation = vi.mocked(runAgentReconcileOperation);
const mockedExecuteAgentReconcileCommands = vi.mocked(executeAgentReconcileCommands);
beforeEach(() => {
vi.useFakeTimers();
mockedRunHistorySyncOperation.mockReset();
mockedRunHistorySyncOperation.mockResolvedValue([]);
mockedExecuteHistorySyncCommands.mockReset();
mockedRunAgentReconcileOperation.mockReset();
mockedRunAgentReconcileOperation.mockResolvedValue([]);
mockedExecuteAgentReconcileCommands.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("runs reconcile immediately and every 3000ms while connected then cleans up", async () => {
const ctx = renderController({
focusedAgentId: null,
focusedAgentRunning: false,
});
await act(async () => {
await Promise.resolve();
});
expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(2999);
expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(3000);
expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(3);
ctx.unmount();
await vi.advanceTimersByTimeAsync(6000);
expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(3);
});
it("polls focused running history every 4500ms and stops when focus no longer running", async () => {
const ctx = renderController({
agents: [createAgent({ status: "running", historyLoadedAt: 1234 })],
focusedAgentId: "agent-1",
focusedAgentRunning: true,
});
await act(async () => {
await Promise.resolve();
});
expect(mockedRunHistorySyncOperation).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(4500);
expect(mockedRunHistorySyncOperation).toHaveBeenCalledTimes(2);
ctx.rerenderWith({
agents: [createAgent({ status: "idle", historyLoadedAt: 1234 })],
focusedAgentId: "agent-1",
focusedAgentRunning: false,
});
await vi.advanceTimersByTimeAsync(9000);
expect(mockedRunHistorySyncOperation).toHaveBeenCalledTimes(2);
});
it("bootstraps history only for connected sessions missing loaded history", async () => {
renderController({
status: "connected",
focusedAgentId: null,
focusedAgentRunning: false,
agents: [
createAgent({ agentId: "agent-1", sessionCreated: true, historyLoadedAt: null }),
createAgent({ agentId: "agent-2", sessionCreated: true, historyLoadedAt: 1234 }),
createAgent({ agentId: "agent-3", sessionCreated: false, historyLoadedAt: null }),
],
});
await act(async () => {
await Promise.resolve();
});
const bootstrappedAgentIds = mockedRunHistorySyncOperation.mock.calls
.map(([arg]) => (arg as { agentId: string }).agentId)
.filter((agentId) => agentId === "agent-1" || agentId === "agent-2" || agentId === "agent-3");
expect(bootstrappedAgentIds).toContain("agent-1");
expect(bootstrappedAgentIds).not.toContain("agent-2");
expect(bootstrappedAgentIds).not.toContain("agent-3");
});
it("loads summary snapshot when status transitions to connected", async () => {
const ctx = renderController({
status: "disconnected",
focusedAgentId: null,
focusedAgentRunning: false,
agents: [createAgent({ sessionCreated: true, historyLoadedAt: 1234 })],
});
expect(ctx.call).not.toHaveBeenCalledWith("status", {});
ctx.rerenderWith({ status: "connected" });
await act(async () => {
await Promise.resolve();
});
expect(ctx.call).toHaveBeenCalledWith("status", {});
expect(ctx.call).toHaveBeenCalledWith("sessions.preview", {
keys: ["agent:agent-1:main"],
limit: 8,
maxChars: 240,
});
});
it("handles gap recovery by triggering summary refresh and reconcile", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx = renderController({
agents: [createAgent({ status: "running", historyLoadedAt: 1234, runId: "run-1" })],
});
await act(async () => {
await Promise.resolve();
});
expect(ctx.onGap).toHaveBeenCalledTimes(1);
const handler = ctx.getGapHandler();
if (!handler) {
throw new Error("expected gap handler to be registered");
}
mockedRunAgentReconcileOperation.mockClear();
ctx.call.mockClear();
await act(async () => {
handler({ expected: 10, received: 11 });
await Promise.resolve();
});
expect(ctx.call).toHaveBeenCalledWith("status", {});
expect(ctx.call).toHaveBeenCalledWith("sessions.preview", {
keys: ["agent:agent-1:main"],
limit: 8,
maxChars: 240,
});
expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith("Gateway event gap expected 10, received 11.");
});
it("unsubscribes gap listener on unmount", () => {
const ctx = renderController();
ctx.unmount();
expect(ctx.unsubscribeGap).toHaveBeenCalledTimes(1);
});
it("clears history in-flight tracking when requested", async () => {
const inFlightSeen: boolean[] = [];
mockedRunHistorySyncOperation.mockImplementation(
async ({ agentId, getAgent, inFlightSessionKeys }) => {
const agent = getAgent(agentId);
if (!agent) return [];
const sessionKey = agent.sessionKey;
inFlightSeen.push(inFlightSessionKeys.has(sessionKey));
inFlightSessionKeys.add(sessionKey);
return [];
}
);
const ctx = renderController({
status: "disconnected",
agents: [createAgent({ sessionKey: "agent:agent-1:main", historyLoadedAt: null })],
focusedAgentId: null,
focusedAgentRunning: false,
});
await act(async () => {
await ctx.getValue().loadAgentHistory("agent-1");
});
await act(async () => {
await ctx.getValue().loadAgentHistory("agent-1");
});
act(() => {
ctx.getValue().clearHistoryInFlight("agent:agent-1:main");
});
await act(async () => {
await ctx.getValue().loadAgentHistory("agent-1");
});
expect(inFlightSeen).toEqual([false, true, false]);
});
});
@@ -0,0 +1,336 @@
import { createElement, useEffect } from "react";
import { act, render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
useSettingsRouteController,
type SettingsRouteController,
type UseSettingsRouteControllerParams,
} from "@/features/agents/operations/useSettingsRouteController";
import type {
InspectSidebarState,
SettingsRouteTab,
} from "@/features/agents/operations/settingsRouteWorkflow";
type OverrideParams = Partial<
Omit<
UseSettingsRouteControllerParams,
| "flushPendingDraft"
| "dispatchSelectAgent"
| "setInspectSidebar"
| "setMobilePaneChat"
| "setPersonalityHasUnsavedChanges"
| "push"
| "replace"
| "confirmDiscard"
>
>;
type RenderControllerContext = {
getValue: () => SettingsRouteController;
rerenderWith: (overrides: OverrideParams) => void;
flushPendingDraft: ReturnType<typeof vi.fn<(agentId: string | null) => void>>;
dispatchSelectAgent: ReturnType<typeof vi.fn<(agentId: string | null) => void>>;
setInspectSidebar: ReturnType<
typeof vi.fn<
(
next: InspectSidebarState | ((current: InspectSidebarState) => InspectSidebarState)
) => void
>
>;
setMobilePaneChat: ReturnType<typeof vi.fn<() => void>>;
setPersonalityHasUnsavedChanges: ReturnType<typeof vi.fn<(next: boolean) => void>>;
push: ReturnType<typeof vi.fn<(href: string) => void>>;
replace: ReturnType<typeof vi.fn<(href: string) => void>>;
confirmDiscard: ReturnType<typeof vi.fn<() => boolean>>;
};
const renderController = (
overrides?: OverrideParams,
callbackOverrides?: Partial<
Pick<
UseSettingsRouteControllerParams,
| "flushPendingDraft"
| "dispatchSelectAgent"
| "setInspectSidebar"
| "setMobilePaneChat"
| "setPersonalityHasUnsavedChanges"
| "push"
| "replace"
| "confirmDiscard"
>
>
): RenderControllerContext => {
const flushPendingDraft = vi.fn<(agentId: string | null) => void>(
callbackOverrides?.flushPendingDraft ?? (() => undefined)
);
const dispatchSelectAgent = vi.fn<(agentId: string | null) => void>(
callbackOverrides?.dispatchSelectAgent ?? (() => undefined)
);
const setInspectSidebar = vi.fn<
(
next: InspectSidebarState | ((current: InspectSidebarState) => InspectSidebarState)
) => void
>(callbackOverrides?.setInspectSidebar ?? (() => undefined));
const setMobilePaneChat = vi.fn<() => void>(
callbackOverrides?.setMobilePaneChat ?? (() => undefined)
);
const setPersonalityHasUnsavedChanges = vi.fn<(next: boolean) => void>(
callbackOverrides?.setPersonalityHasUnsavedChanges ?? (() => undefined)
);
const push = vi.fn<(href: string) => void>(callbackOverrides?.push ?? (() => undefined));
const replace = vi.fn<(href: string) => void>(callbackOverrides?.replace ?? (() => undefined));
const confirmDiscard = vi.fn<() => boolean>(callbackOverrides?.confirmDiscard ?? (() => true));
let currentParams: UseSettingsRouteControllerParams = {
settingsRouteActive: false,
settingsRouteAgentId: null,
status: "connected",
agentsLoadedOnce: true,
selectedAgentId: null,
focusedAgentId: null,
personalityHasUnsavedChanges: false,
activeTab: "personality",
inspectSidebar: null,
agents: [{ agentId: "agent-1" }],
flushPendingDraft,
dispatchSelectAgent,
setInspectSidebar,
setMobilePaneChat,
setPersonalityHasUnsavedChanges,
push,
replace,
confirmDiscard,
...overrides,
};
const valueRef: { current: SettingsRouteController | null } = { current: null };
const Probe = ({
params,
onValue,
}: {
params: UseSettingsRouteControllerParams;
onValue: (value: SettingsRouteController) => void;
}) => {
const value = useSettingsRouteController(params);
useEffect(() => {
onValue(value);
}, [onValue, value]);
return createElement("div", { "data-testid": "probe" }, "ok");
};
const rendered = render(
createElement(Probe, {
params: currentParams,
onValue: (value) => {
valueRef.current = value;
},
})
);
return {
getValue: () => {
if (!valueRef.current) throw new Error("controller value unavailable");
return valueRef.current;
},
rerenderWith: (nextOverrides) => {
currentParams = {
...currentParams,
...nextOverrides,
};
rendered.rerender(
createElement(Probe, {
params: currentParams,
onValue: (value) => {
valueRef.current = value;
},
})
);
},
flushPendingDraft,
dispatchSelectAgent,
setInspectSidebar,
setMobilePaneChat,
setPersonalityHasUnsavedChanges,
push,
replace,
confirmDiscard,
};
};
describe("useSettingsRouteController", () => {
it("blocks back-to-chat when personality discard is declined", () => {
const ctx = renderController(
{
settingsRouteActive: true,
activeTab: "personality",
personalityHasUnsavedChanges: true,
},
{
confirmDiscard: () => false,
}
);
act(() => {
ctx.getValue().handleBackToChat();
});
expect(ctx.confirmDiscard).toHaveBeenCalledTimes(1);
expect(ctx.setPersonalityHasUnsavedChanges).not.toHaveBeenCalled();
expect(ctx.push).not.toHaveBeenCalled();
});
it("changes settings tab only after confirmed discard and clears dirty flag", () => {
const ctx = renderController(
{
settingsRouteActive: true,
settingsRouteAgentId: "agent-1",
inspectSidebar: { agentId: "agent-1", tab: "personality" },
activeTab: "personality",
personalityHasUnsavedChanges: true,
},
{
confirmDiscard: () => true,
}
);
act(() => {
ctx.getValue().handleSettingsRouteTabChange("capabilities");
});
expect(ctx.confirmDiscard).toHaveBeenCalledTimes(1);
expect(ctx.setPersonalityHasUnsavedChanges).toHaveBeenCalledWith(false);
expect(ctx.setInspectSidebar).toHaveBeenCalledWith({
agentId: "agent-1",
tab: "capabilities",
});
});
it("runs open-settings commands in order and encodes route", () => {
const order: string[] = [];
const ctx = renderController(
{
focusedAgentId: "focused-agent",
inspectSidebar: null,
},
{
flushPendingDraft: () => {
order.push("flush");
},
dispatchSelectAgent: () => {
order.push("select");
},
setInspectSidebar: () => {
order.push("inspect");
},
setMobilePaneChat: () => {
order.push("pane");
},
push: (href) => {
order.push(`push:${href}`);
},
}
);
order.length = 0;
act(() => {
ctx.getValue().handleOpenAgentSettingsRoute("agent 2");
});
expect(order).toEqual([
"flush",
"select",
"inspect",
"pane",
"push:/agents/agent%202/settings",
]);
});
it("keeps fleet-select behavior parity", () => {
const ctx = renderController({
focusedAgentId: "focused-agent",
inspectSidebar: { agentId: "agent-1", tab: "automations" },
});
act(() => {
ctx.getValue().handleFleetSelectAgent("agent-9");
});
expect(ctx.flushPendingDraft).toHaveBeenCalledWith("focused-agent");
expect(ctx.dispatchSelectAgent).toHaveBeenCalledWith("agent-9");
expect(ctx.setInspectSidebar).toHaveBeenCalledWith({
agentId: "agent-9",
tab: "automations",
});
expect(ctx.setMobilePaneChat).toHaveBeenCalledTimes(1);
});
it("redirects to root when route agent is missing after load", async () => {
const ctx = renderController({
settingsRouteActive: true,
settingsRouteAgentId: "missing-agent",
status: "connected",
agentsLoadedOnce: true,
agents: [{ agentId: "agent-1" }],
});
await waitFor(() => {
expect(ctx.replace).toHaveBeenCalledWith("/");
});
});
it("syncs route agent into inspect sidebar and selected agent", async () => {
const ctx = renderController({
settingsRouteActive: true,
settingsRouteAgentId: "agent-1",
selectedAgentId: null,
inspectSidebar: null,
agents: [{ agentId: "agent-1" }],
});
await waitFor(() => {
expect(ctx.setInspectSidebar).toHaveBeenCalledWith({
agentId: "agent-1",
tab: "personality",
});
expect(ctx.dispatchSelectAgent).toHaveBeenCalledWith("agent-1");
});
});
it("does not dispatch or mutate when non-route selection is already aligned", async () => {
const ctx = renderController({
settingsRouteActive: false,
selectedAgentId: "agent-1",
focusedAgentId: "agent-1",
inspectSidebar: null,
agents: [{ agentId: "agent-1" }],
});
await waitFor(() => {
expect(ctx.dispatchSelectAgent).not.toHaveBeenCalled();
expect(ctx.setInspectSidebar).not.toHaveBeenCalled();
expect(ctx.replace).not.toHaveBeenCalled();
});
});
it("does not call confirm when switching non-personality tabs", () => {
const ctx = renderController({
settingsRouteActive: true,
settingsRouteAgentId: "agent-1",
inspectSidebar: { agentId: "agent-1", tab: "capabilities" },
activeTab: "capabilities" satisfies SettingsRouteTab,
personalityHasUnsavedChanges: true,
});
act(() => {
ctx.getValue().handleSettingsRouteTabChange("automations");
});
expect(ctx.confirmDiscard).not.toHaveBeenCalled();
expect(ctx.setInspectSidebar).toHaveBeenCalledWith({
agentId: "agent-1",
tab: "automations",
});
});
});