mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 08:52:03 +00:00
Improve ExecPlan accuracy
This commit is contained in:
+384
-1442
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user