diff --git a/ui/src/pages/custodian/custodian-qr-session.ts b/ui/src/pages/custodian/custodian-qr-session.ts new file mode 100644 index 000000000000..3a3299a77bd5 --- /dev/null +++ b/ui/src/pages/custodian/custodian-qr-session.ts @@ -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); + } +} diff --git a/ui/src/pages/custodian/custodian-session-request.ts b/ui/src/pages/custodian/custodian-session-request.ts new file mode 100644 index 000000000000..3166da710742 --- /dev/null +++ b/ui/src/pages/custodian/custodian-session-request.ts @@ -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"; +} diff --git a/ui/src/pages/custodian/custodian-session-state.ts b/ui/src/pages/custodian/custodian-session-state.ts new file mode 100644 index 000000000000..a2c38ce00758 --- /dev/null +++ b/ui/src/pages/custodian/custodian-session-state.ts @@ -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; +} diff --git a/ui/src/pages/custodian/custodian-session-store.ts b/ui/src/pages/custodian/custodian-session-store.ts index 24e0d25fb5ee..c9ff05d85326 100644 --- a/ui/src/pages/custodian/custodian-session-store.ts +++ b/ui/src/pages/custodian/custodian-session-store.ts @@ -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(); - 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 { - 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) }); } } diff --git a/ui/src/pages/custodian/transcript.ts b/ui/src/pages/custodian/transcript.ts index 92d6e1e8ef99..0197b6182d9e 100644 --- a/ui/src/pages/custodian/transcript.ts +++ b/ui/src/pages/custodian/transcript.ts @@ -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,