mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(ui): split Custodian QR session coordination
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user