refactor(ui): split Custodian QR session coordination

This commit is contained in:
jesse-merhi
2026-08-12 01:47:39 +10:00
parent 54a4cd92e6
commit 247749b754
5 changed files with 387 additions and 210 deletions
@@ -0,0 +1,137 @@
import type { SystemAgentChatResult } from "@openclaw/gateway-protocol";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
CustodianQrScheduler,
findCustodianQrStep,
replaceCustodianQrStep,
scrubCustodianQrSteps,
} from "./custodian-wizard-step.ts";
import * as eventNudgeState from "./event-nudge.ts";
import { isCustodianSessionInvalidatedError } from "./session-lifecycle.ts";
import type { CustodianMessage } from "./transcript.ts";
type CustodianQrState = {
messages: CustodianMessage[];
wizardInputPending: boolean;
wizardSettling: boolean;
questionReplyUncertain: boolean;
abandonedTurnOutcomeUnknown: boolean;
error: string | null;
};
export class CustodianQrSession {
private readonly scheduler: CustodianQrScheduler;
constructor(
private readonly state: CustodianQrState,
callbacks: {
emit: () => void;
poll: (client: GatewayBrowserClient, stepId: string, generation: number) => void;
invalidate: (client: GatewayBrowserClient) => void;
},
) {
this.scheduler = new CustodianQrScheduler({
onExpire: (stepId, notify) => {
this.scrub(stepId);
if (notify) {
callbacks.emit();
}
},
onPoll: callbacks.poll,
});
this.invalidate = callbacks.invalidate;
}
private readonly invalidate: (client: GatewayBrowserClient) => void;
clear(): void {
this.scheduler.clear();
}
clearAndScrub(stepId?: string): void {
this.clear();
this.scrub(stepId);
}
scrub(stepId?: string): void {
this.state.messages = scrubCustodianQrSteps(this.state.messages, stepId);
}
scheduleStep(client: GatewayBrowserClient, result: SystemAgentChatResult): void {
if (result.step) {
this.scheduler.scheduleStep(client, result.step);
}
}
schedulePoll(client: GatewayBrowserClient, stepId: string): void {
this.scheduler.schedulePoll(client, stepId);
}
pendingStepId(active: boolean): string | undefined {
return active
? this.state.messages.findLast((message) => message.step?.type === "qr")?.step?.id
: undefined;
}
projectPoll(params: {
client: GatewayBrowserClient;
result: SystemAgentChatResult;
stepId: string;
presentationGeneration?: number;
}): boolean {
const { client, result, stepId } = params;
this.state.error = null;
if (result.step?.type === "qr" && result.step.id === stepId) {
if (
params.presentationGeneration !== undefined &&
!this.scheduler.isPollPresentationCurrent(stepId, params.presentationGeneration)
) {
// Expiry retired the credential while this request was in flight. Keep observing,
// but never let that stale response restore the scrubbed QR bytes.
this.scrub(stepId);
this.schedulePoll(client, stepId);
return true;
}
this.state.messages = replaceCustodianQrStep(this.state.messages, result.step);
this.state.wizardInputPending = result.wizardInputPending === true;
this.state.wizardSettling = result.wizardSettling === true;
this.scheduler.scheduleStep(client, result.step);
return true;
}
if (result.wizardSettling === true && result.step === undefined) {
// The external owner can outlive its presentation. Keep polling so typed Cancel
// remains available until the owner reports success, failure, or cancellation.
this.scrub(stepId);
this.state.questionReplyUncertain = false;
this.state.wizardInputPending = false;
this.state.wizardSettling = true;
this.schedulePoll(client, stepId);
return true;
}
return false;
}
settlePoll(stepId: string): void {
this.clearAndScrub(stepId);
this.state.questionReplyUncertain = false;
this.state.abandonedTurnOutcomeUnknown = false;
}
handlePollError(params: {
client: GatewayBrowserClient;
stepId: string;
error: unknown;
delivery: eventNudgeState.CustodianSendDelivery;
}): eventNudgeState.CustodianSendOutcome {
if (isCustodianSessionInvalidatedError(params.error)) {
this.clearAndScrub(params.stepId);
this.invalidate(params.client);
return "sent";
}
const step = findCustodianQrStep(this.state.messages, params.stepId);
if (step) {
this.schedulePoll(params.client, step.id);
}
return eventNudgeState.classifyCustodianSendFailure(params.error, params.delivery);
}
}
@@ -0,0 +1,77 @@
import {
readSystemAgentInferenceUnavailableErrorDetails,
type SystemAgentChatResult,
} from "@openclaw/gateway-protocol";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import type { CustodianConfiguredInferenceState } from "./custodian-session-state.ts";
import { initialCustodianWizardValue } from "./custodian-wizard-step.ts";
import { parseCustodianQuestion } from "./structured-question.ts";
import {
createCustodianAssistantMessage,
createCustodianTranscriptMessages,
readCustodianTranscript,
type CustodianMessage,
} from "./transcript.ts";
export async function loadCustodianTranscriptProjection(params: {
client: GatewayBrowserClient;
context: ApplicationContext | null;
isCurrent: () => boolean;
firstMessageId: number;
}): Promise<{ messages: CustodianMessage[]; nextMessageId: number } | null> {
if (
!params.context ||
isGatewayMethodAdvertised(params.context.gateway.snapshot, "openclaw.chat.history") !== true
) {
return null;
}
const turns = await readCustodianTranscript(params.client);
return turns === null || !params.isCurrent()
? null
: createCustodianTranscriptMessages(turns, params.firstMessageId);
}
export function projectCustodianChatResult(
result: SystemAgentChatResult,
nextMessageId: number,
silentReply: boolean,
): {
sensitive: boolean;
wizardInputPending: boolean;
wizardSettling: boolean;
wizardValue: unknown;
message: CustodianMessage | null;
} {
const step = result.step ?? null;
const question = step ? null : parseCustodianQuestion(result.question);
return {
sensitive: result.sensitive === true,
wizardInputPending: result.wizardInputPending === true,
wizardSettling: result.wizardSettling === true,
wizardValue: step ? initialCustodianWizardValue(step) : undefined,
message:
!silentReply || question || step
? createCustodianAssistantMessage({
id: nextMessageId,
text: silentReply ? "" : result.reply,
question,
step,
})
: null,
};
}
export function resolveCustodianSetupIssue(
error: unknown,
configuredInferenceState: CustodianConfiguredInferenceState,
): "missing" | "unavailable" | null {
const details =
error && typeof error === "object" ? (error as { details?: unknown }).details : undefined;
return readSystemAgentInferenceUnavailableErrorDetails(details) === undefined
? null
: configuredInferenceState === "required"
? "missing"
: "unavailable";
}
@@ -0,0 +1,83 @@
import type { SystemAgentChatParams } from "@openclaw/gateway-protocol";
import type { ApplicationContext } from "../../app/context.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
export type CustodianConfiguredInferenceState = "unresolved" | "required" | "ready";
type CustodianWizardState = {
wizardValue: unknown;
wizardSecretVisible: boolean;
sensitive: boolean;
wizardInputPending: boolean;
wizardSettling: boolean;
questionReplyUncertain: boolean;
};
export function hasCustodianUserInput(params: SystemAgentChatParams): boolean {
return (
params.message !== undefined ||
params.wizardAnswer !== undefined ||
params.wizardCancel !== undefined
);
}
export function resolveCustodianConfiguredInferenceState(
context: ApplicationContext | null,
): CustodianConfiguredInferenceState {
if (!context || context.gateway.snapshot.phase !== "connected") {
return "unresolved";
}
const agentsList = context.agents.state.agentsList;
if (!agentsList) {
return "unresolved";
}
const selectedId = normalizeAgentId(
context.gateway.snapshot.assistantAgentId ?? agentsList.defaultId ?? "",
);
const selectedAgent = agentsList.agents.find(
(agent) => normalizeAgentId(agent.id) === selectedId,
);
if (!selectedAgent) {
return "unresolved";
}
return selectedAgent.model?.primary?.trim() ? "ready" : "required";
}
function resolveCustodianSessionOwnership(params: {
context: ApplicationContext | null;
lastHelloDeviceToken: string;
}): { key: string; lastHelloDeviceToken: string } {
const context = params.context;
if (!context) {
return { key: "", lastHelloDeviceToken: params.lastHelloDeviceToken };
}
const { gatewayUrl, token, password, bootstrapToken } = context.gateway.connection;
const auth = context.gateway.snapshot.hello?.auth;
const lastHelloDeviceToken = auth ? (auth.deviceToken ?? "") : params.lastHelloDeviceToken;
return {
key: JSON.stringify([gatewayUrl, token, password, bootstrapToken, lastHelloDeviceToken]),
lastHelloDeviceToken,
};
}
export class CustodianSessionState {
private lastHelloDeviceToken = "";
ownershipKey(context: ApplicationContext | null): string {
const ownership = resolveCustodianSessionOwnership({
context,
lastHelloDeviceToken: this.lastHelloDeviceToken,
});
this.lastHelloDeviceToken = ownership.lastHelloDeviceToken;
return ownership.key;
}
}
export function resetCustodianWizardState(state: CustodianWizardState): void {
state.wizardValue = undefined;
state.wizardSecretVisible = false;
state.sensitive = false;
state.wizardInputPending = false;
state.wizardSettling = false;
state.questionReplyUncertain = false;
}
+77 -210
View File
@@ -1,42 +1,39 @@
import {
GATEWAY_SERVER_CAPS,
readSystemAgentInferenceUnavailableErrorDetails,
type SystemAgentChatParams,
type SystemAgentChatResult,
} from "@openclaw/gateway-protocol";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { WizardStep } from "../../api/types.ts";
import { selectApplicationSession } from "../../app/agent-selection.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import {
canCallGatewayMethod,
isGatewayCapabilityAdvertised,
isGatewayMethodAdvertised,
} from "../../lib/gateway-methods.ts";
import { buildAgentMainSessionKey, normalizeAgentId } from "../../lib/sessions/session-key.ts";
import { canCallGatewayMethod, isGatewayCapabilityAdvertised } from "../../lib/gateway-methods.ts";
import { buildAgentMainSessionKey } from "../../lib/sessions/session-key.ts";
import { pathForCustodianAgentHandoff } from "./custodian-navigation.ts";
import { CustodianQrSession } from "./custodian-qr-session.ts";
import {
CustodianQrScheduler,
custodianWizardSubmission,
findCustodianQrStep,
initialCustodianWizardValue,
replaceCustodianQrStep,
scrubCustodianQrSteps,
} from "./custodian-wizard-step.ts";
loadCustodianTranscriptProjection,
projectCustodianChatResult,
resolveCustodianSetupIssue,
} from "./custodian-session-request.ts";
import {
hasCustodianUserInput,
resetCustodianWizardState,
resolveCustodianConfiguredInferenceState,
type CustodianConfiguredInferenceState,
CustodianSessionState,
} from "./custodian-session-state.ts";
import { custodianWizardSubmission } from "./custodian-wizard-step.ts";
import * as eventNudgeState from "./event-nudge.ts";
import {
custodianChatParams,
isCustodianSessionInvalidatedError,
type CustodianSessionVariant,
} from "./session-lifecycle.ts";
import { parseCustodianQuestion, type CustodianStructuredQuestion } from "./structured-question.ts";
import {
createCustodianSessionId,
createCustodianTranscriptMessages,
custodianErrorMessage,
hasUnresolvedCustodianQuestion,
readCustodianTranscript,
retireCustodianQuestions,
type CustodianMessage,
} from "./transcript.ts";
@@ -44,16 +41,7 @@ import {
const SYSTEM_AGENT_CHAT_TIMEOUT_MS = 190_000;
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
function hasCustodianUserInput(params: SystemAgentChatParams): boolean {
return (
params.message !== undefined ||
params.wizardAnswer !== undefined ||
params.wizardCancel !== undefined
);
}
type StoreListener = () => void;
type ConfiguredInferenceState = "unresolved" | "required" | "ready";
type CustodianSetupIssue = "missing" | "unavailable";
/** One process-local conversation owner shared by the full page and dock surface. */
@@ -90,27 +78,23 @@ export class CustodianSessionStore {
private sessionClient: GatewayBrowserClient | null = null;
private sessionOwnershipKey: string | null = null;
private sessionStarted = false;
private lastHelloDeviceToken = "";
private configuredInferenceState: ConfiguredInferenceState = "unresolved";
private configuredInferenceState: CustodianConfiguredInferenceState = "unresolved";
private readonly sessionState = new CustodianSessionState();
private eventNudgeClosed = false;
private gatewayCleanup: (() => void) | null = null;
private agentCleanup: (() => void) | null = null;
private eventCleanup: (() => void) | null = null;
private readonly listeners = new Set<StoreListener>();
private readonly qrScheduler = new CustodianQrScheduler({
onExpire: (stepId, notify) => {
this.messages = scrubCustodianQrSteps(this.messages, stepId);
if (notify) {
this.emit();
}
},
onPoll: (client, stepId, presentationGeneration) => {
private readonly qrSession = new CustodianQrSession(this, {
emit: () => this.emit(),
poll: (client, stepId, presentationGeneration) => {
void this.requestReply(
client,
{ sessionId: this.sessionId, pollStepId: stepId },
{ pollStepId: stepId, qrPresentationGeneration: presentationGeneration },
);
},
invalidate: (client) => this.rotateVolatileSession(client, this.variant),
});
subscribe(listener: StoreListener): () => void {
@@ -385,8 +369,7 @@ export class CustodianSessionStore {
if (step.type === "qr") {
// Cancellation is the only client mutation for a passive QR. Stop its poll before
// sending so a timer cannot abort the owner-controlled cancellation request.
this.qrScheduler.clear();
this.messages = scrubCustodianQrSteps(this.messages, step.id);
this.qrSession.clearAndScrub(step.id);
}
void this.sendUserTurn(
client,
@@ -404,8 +387,7 @@ export class CustodianSessionStore {
}
private revokeNavigationAuthority(): void {
this.qrScheduler.clear();
this.messages = scrubCustodianQrSteps(this.messages);
this.qrSession.clearAndScrub();
this.requestAbort?.abort();
this.requestAbort = null;
this.requestEpoch += 1;
@@ -426,19 +408,6 @@ export class CustodianSessionStore {
}
}
private currentSessionOwnershipKey(): string {
const context = this.context;
if (!context) {
return "";
}
const { gatewayUrl, token, password, bootstrapToken } = context.gateway.connection;
const auth = context.gateway.snapshot.hello?.auth;
if (auth) {
this.lastHelloDeviceToken = auth.deviceToken ?? "";
}
return JSON.stringify([gatewayUrl, token, password, bootstrapToken, this.lastHelloDeviceToken]);
}
private startSession(
client: GatewayBrowserClient,
variant: CustodianSessionVariant,
@@ -447,7 +416,7 @@ export class CustodianSessionStore {
this.sessionId = createCustodianSessionId();
this.sessionVariant = variant;
this.sessionClient = client;
this.sessionOwnershipKey = this.currentSessionOwnershipKey();
this.sessionOwnershipKey = this.sessionState.ownershipKey(this.context);
this.sessionStarted = true;
void this.initializeSession(
client,
@@ -472,13 +441,7 @@ export class CustodianSessionStore {
this.answeredQuestions = retireCustodianQuestions(this.messages, this.answeredQuestions);
this.retryParams = null;
this.input = "";
this.wizardValue = undefined;
this.wizardSecretVisible = false;
this.sensitive =
this.wizardInputPending =
this.wizardSettling =
this.questionReplyUncertain =
false;
resetCustodianWizardState(this);
this.error = null;
this.setupIssue = null;
this.earlierBoundaryAfterId = this.messages.at(-1)?.id ?? null;
@@ -494,11 +457,11 @@ export class CustodianSessionStore {
const client = snapshot.phase === "connected" ? snapshot.client : null;
const chatSupported =
client !== null && canCallGatewayMethod(snapshot, "openclaw.chat", "operator.admin");
const configuredInferenceState = this.resolveConfiguredInferenceState();
const configuredInferenceState = resolveCustodianConfiguredInferenceState(this.context);
const inferenceStateChanged = configuredInferenceState !== this.configuredInferenceState;
this.configuredInferenceState = configuredInferenceState;
const variantChanged = this.sessionStarted && this.sessionVariant !== this.variant;
const ownershipKey = this.currentSessionOwnershipKey();
const ownershipKey = this.sessionState.ownershipKey(this.context);
const clientReplaced =
this.sessionStarted &&
client !== null &&
@@ -506,10 +469,9 @@ export class CustodianSessionStore {
client !== this.sessionClient;
const ownershipChanged =
this.sessionOwnershipKey !== null && ownershipKey !== this.sessionOwnershipKey;
const pendingQrStepId =
this.wizardInputPending || this.wizardSettling
? this.messages.findLast((message) => message.step?.type === "qr")?.step?.id
: undefined;
const pendingQrStepId = this.qrSession.pendingStepId(
this.wizardInputPending || this.wizardSettling,
);
if (
client === this.activeClient &&
!variantChanged &&
@@ -520,8 +482,7 @@ export class CustodianSessionStore {
) {
return;
}
this.qrScheduler.clear();
this.messages = scrubCustodianQrSteps(this.messages);
this.qrSession.clearAndScrub();
const requestWasPending = this.sending && this.retryParams !== null;
const pendingParams = requestWasPending ? this.retryParams : null;
this.activeClient = client;
@@ -550,10 +511,10 @@ export class CustodianSessionStore {
this.retryParams = null;
this.error = null;
this.sessionClient = client;
this.qrScheduler.schedulePoll(client, pendingQrStepId);
this.qrSession.schedulePoll(client, pendingQrStepId);
return;
}
this.rotateVolatileSession(client, this.currentSessionVariant());
this.rotateVolatileSession(client, this.variant);
return;
} else if (requestWasPending) {
if (pendingParams?.message === undefined) {
@@ -591,37 +552,12 @@ export class CustodianSessionStore {
: null;
if (pendingStep?.type === "qr") {
// A reconnect invalidates the old timer, but the Gateway still owns the QR session.
this.qrScheduler.schedulePoll(client, pendingStep.id);
this.qrSession.schedulePoll(client, pendingStep.id);
}
return;
}
this.clearConversation();
this.startSession(client, this.currentSessionVariant(), true);
}
private resolveConfiguredInferenceState(): ConfiguredInferenceState {
const context = this.context;
if (!context || context.gateway.snapshot.phase !== "connected") {
return "unresolved";
}
const agentsList = context.agents.state.agentsList;
if (!agentsList) {
return "unresolved";
}
const selectedId = normalizeAgentId(
context.gateway.snapshot.assistantAgentId ?? agentsList.defaultId ?? "",
);
const selectedAgent = agentsList.agents.find(
(agent) => normalizeAgentId(agent.id) === selectedId,
);
if (!selectedAgent) {
return "unresolved";
}
return selectedAgent.model?.primary?.trim() ? "ready" : "required";
}
private currentSessionVariant(): CustodianSessionVariant {
return this.variant;
this.startSession(client, this.variant, true);
}
private async initializeSession(
@@ -635,7 +571,18 @@ export class CustodianSessionStore {
this.retryParams = params;
this.emit();
if (loadTranscript) {
await this.refreshTranscriptHistory(client, epoch);
const transcript = await loadCustodianTranscriptProjection({
client,
context: this.context,
firstMessageId: this.nextMessageId,
isCurrent: () => epoch === this.requestEpoch && client === this.activeClient,
});
if (transcript) {
this.messages = transcript.messages;
this.nextMessageId = transcript.nextMessageId;
this.earlierBoundaryAfterId = this.messages.at(-1)?.id ?? null;
this.emit();
}
}
if (epoch !== this.requestEpoch || client !== this.activeClient) {
return;
@@ -643,30 +590,8 @@ export class CustodianSessionStore {
await this.requestReply(client, params);
}
private async refreshTranscriptHistory(
client: GatewayBrowserClient,
epoch: number,
): Promise<void> {
const context = this.context;
if (
!context ||
isGatewayMethodAdvertised(context.gateway.snapshot, "openclaw.chat.history") !== true
) {
return;
}
const turns = await readCustodianTranscript(client);
if (turns === null || epoch !== this.requestEpoch || client !== this.activeClient) {
return;
}
const transcript = createCustodianTranscriptMessages(turns, this.nextMessageId);
this.messages = transcript.messages;
this.nextMessageId = transcript.nextMessageId;
this.earlierBoundaryAfterId = this.messages.at(-1)?.id ?? null;
this.emit();
}
private clearConversation(): void {
this.qrScheduler.clear();
this.qrSession.clear();
this.messages = [];
this.dismissedQuestions = new Set();
this.answeredQuestions = new Set();
@@ -674,34 +599,10 @@ export class CustodianSessionStore {
this.error = null;
this.setupIssue = null;
this.input = "";
this.wizardValue = undefined;
this.wizardSecretVisible = false;
this.sensitive =
this.wizardInputPending =
this.wizardSettling =
this.questionReplyUncertain =
false;
resetCustodianWizardState(this);
this.earlierBoundaryAfterId = null;
}
private appendAssistant(
reply: string,
question: CustodianStructuredQuestion | null,
step: WizardStep | null,
): void {
this.messages = [
...this.messages,
{
id: this.nextMessageId++,
role: "assistant",
text: reply,
at: Date.now(),
question,
step,
},
];
}
private async requestReply(
client: GatewayBrowserClient,
params: SystemAgentChatParams,
@@ -725,7 +626,6 @@ export class CustodianSessionStore {
const requestAbort = new AbortController();
this.requestAbort = requestAbort;
const pollStepId = options?.pollStepId;
const settlingStepId = pollStepId;
const epoch = ++this.requestEpoch;
let delivery: eventNudgeState.CustodianSendDelivery = "unsent";
if (!pollStepId) {
@@ -748,57 +648,40 @@ export class CustodianSessionStore {
return "sent";
}
this.sessionId = result.sessionId;
if (pollStepId) {
// An authoritative QR observation supersedes a prior poll failure.
this.error = null;
}
if (pollStepId && result.step?.type === "qr" && result.step.id === pollStepId) {
if (
options.qrPresentationGeneration !== undefined &&
!this.qrScheduler.isPollPresentationCurrent(pollStepId, options.qrPresentationGeneration)
) {
// Expiry retired the credential while this request was in flight. A later poll can
// recover authoritative state, but this stale response must not resurrect QR bytes.
this.messages = scrubCustodianQrSteps(this.messages, pollStepId);
this.qrScheduler.schedulePoll(client, pollStepId);
return "sent";
}
this.messages = replaceCustodianQrStep(this.messages, result.step);
this.wizardInputPending = result.wizardInputPending === true;
this.wizardSettling = result.wizardSettling === true;
this.qrScheduler.scheduleStep(client, result.step);
if (
pollStepId &&
this.qrSession.projectPoll({
client,
result,
stepId: pollStepId,
presentationGeneration: options.qrPresentationGeneration,
})
) {
return "sent";
}
if (settlingStepId && result.wizardSettling === true && result.step === undefined) {
// An externally owned QR can outlive its presentation. Keep observations short and
// poll again so Cancel remains responsive while the owner settles in the background.
this.messages = scrubCustodianQrSteps(this.messages, settlingStepId);
this.questionReplyUncertain = false;
this.wizardInputPending = false;
this.wizardSettling = true;
this.qrScheduler.schedulePoll(client, settlingStepId);
return "sent";
}
this.qrScheduler.clear();
if (pollStepId) {
this.messages = scrubCustodianQrSteps(this.messages, pollStepId);
this.questionReplyUncertain = false;
this.abandonedTurnOutcomeUnknown = false;
this.qrSession.settlePoll(pollStepId);
} else {
this.qrSession.clear();
}
this.sensitive = result.sensitive === true;
this.wizardInputPending = result.wizardInputPending === true;
this.wizardSettling = result.wizardSettling === true;
const projection = projectCustodianChatResult(
result,
this.nextMessageId,
SILENT_REPLY_PATTERN.test(result.reply),
);
this.sensitive = projection.sensitive;
this.wizardInputPending = projection.wizardInputPending;
this.wizardSettling = projection.wizardSettling;
[this.retryParams, this.setupIssue] = [null, null];
const step = result.step ?? null;
const question = step ? null : parseCustodianQuestion(result.question);
this.wizardValue = step ? initialCustodianWizardValue(step) : undefined;
this.wizardValue = projection.wizardValue;
this.wizardSecretVisible = false;
const silentReply = SILENT_REPLY_PATTERN.test(result.reply);
if (!silentReply || question || step) {
this.appendAssistant(silentReply ? "" : result.reply, question, step);
if (projection.message) {
this.messages = [...this.messages, projection.message];
this.nextMessageId += 1;
}
if (step?.type === "qr") {
this.qrScheduler.scheduleStep(client, step);
this.qrSession.scheduleStep(client, result);
}
if (result.action === "open-agent") {
let sessionKey = context.gateway.snapshot.sessionKey?.trim();
@@ -833,32 +716,16 @@ export class CustodianSessionStore {
} catch (error) {
if (pollStepId) {
if (epoch === this.requestEpoch && client === this.activeClient) {
if (isCustodianSessionInvalidatedError(error)) {
this.qrScheduler.clear();
this.messages = scrubCustodianQrSteps(this.messages, pollStepId);
this.rotateVolatileSession(client, this.currentSessionVariant());
return "sent";
}
const step = findCustodianQrStep(this.messages, pollStepId);
if (step) {
this.qrScheduler.schedulePoll(client, step.id);
}
return this.qrSession.handlePollError({ client, stepId: pollStepId, error, delivery });
}
return eventNudgeState.classifyCustodianSendFailure(error, delivery);
}
if (epoch === this.requestEpoch && client === this.activeClient) {
this.error = custodianErrorMessage(error);
const details =
error && typeof error === "object" ? (error as { details?: unknown }).details : undefined;
this.setupIssue =
readSystemAgentInferenceUnavailableErrorDetails(details) !== undefined
? this.configuredInferenceState === "required"
? "missing"
: "unavailable"
: null;
this.setupIssue = resolveCustodianSetupIssue(error, this.configuredInferenceState);
if (hasCustodianUserInput(params) && isCustodianSessionInvalidatedError(error)) {
// Retained transcript rows are display context only; the next turn needs a fresh id.
this.rotateVolatileSession(client, this.currentSessionVariant());
this.rotateVolatileSession(client, this.variant);
this.error = t("custodian.sessionRestarted", { error: custodianErrorMessage(error) });
}
}
+13
View File
@@ -26,6 +26,19 @@ export type CustodianMessage = {
step: WizardStepPresentation | null;
};
export function createCustodianAssistantMessage(params: {
id: number;
text: string;
question: CustodianMessage["question"];
step: CustodianMessage["step"];
}): CustodianMessage {
return {
...params,
role: "assistant",
at: Date.now(),
};
}
export function hasUnresolvedCustodianQuestion(
messages: readonly CustodianMessage[],
dismissedQuestions: ReadonlySet<string>,