Archived
feat(ui): wire extension dialogs into the client data layer
Parse pendingDialogs on the session status and the dialog.opened / dialog.closed socket events, track them per selected session in app state (array, no supersede; closed dialogs kept with their outcome for transient rendering), and add answerDialog/cancelDialog session API methods on the dedicated dialogs routes, allowlisted for remote machines.
This commit is contained in:
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
|
|||||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||||
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
|
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
|
||||||
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
|
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
|
||||||
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, PendingExtensionDialog, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, ExtensionDialogCloseReason, ExtensionDialogCloseResponse, ExtensionDialogKind, ExtensionDialogOutcome, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||||
|
|||||||
@@ -321,6 +321,34 @@ describe("session API compatibility", () => {
|
|||||||
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" });
|
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers and cancels extension dialogs through encoded machine routes", async () => {
|
||||||
|
const answered = {
|
||||||
|
result: "closed",
|
||||||
|
outcome: { dialogId: "dialog 1", reason: "answered", answer: true, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" },
|
||||||
|
sessionStatus: dialogStatusWire(),
|
||||||
|
};
|
||||||
|
const cancelled = { result: "stale", sessionStatus: dialogStatusWire() };
|
||||||
|
const fetchMock = stubSequenceFetch([jsonResponse(answered), jsonResponse(cancelled)]);
|
||||||
|
const ref = { id: "s /?", cwd: "/repo with spaces" };
|
||||||
|
|
||||||
|
await expect(sessionsApi.answerDialog(ref, "dialog 1", true, "remote /?")).resolves.toEqual({
|
||||||
|
result: "closed",
|
||||||
|
outcome: { dialogId: "dialog 1", reason: "answered", answer: true, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" },
|
||||||
|
sessionStatus: parsedDialogStatus(),
|
||||||
|
});
|
||||||
|
await expect(sessionsApi.cancelDialog(ref, "dialog 1", "remote /?")).resolves.toEqual({ result: "stale", sessionStatus: parsedDialogStatus() });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [answerUrl, answerInit] = fetchCall(fetchMock, 0);
|
||||||
|
expect(answerUrl).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/dialogs/answer");
|
||||||
|
expect(answerInit?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(requestBody(answerInit))).toEqual({ cwd: "/repo with spaces", dialogId: "dialog 1", value: true });
|
||||||
|
const [cancelUrl, cancelInit] = fetchCall(fetchMock, 1);
|
||||||
|
expect(cancelUrl).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/dialogs/cancel");
|
||||||
|
expect(cancelInit?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(requestBody(cancelInit))).toEqual({ cwd: "/repo with spaces", dialogId: "dialog 1" });
|
||||||
|
});
|
||||||
|
|
||||||
it("posts session tree navigation through an encoded cwd-scoped machine route", async () => {
|
it("posts session tree navigation through an encoded cwd-scoped machine route", async () => {
|
||||||
const fetchMock = stubJsonFetch({ cancelled: false, editorText: "edit this" });
|
const fetchMock = stubJsonFetch({ cancelled: false, editorText: "edit this" });
|
||||||
const navigation = { targetId: "entry /?", expectedLeafId: "leaf-1", summary: { mode: "custom" as const, instructions: "focus on tests" } };
|
const navigation = { targetId: "entry /?", expectedLeafId: "leaf-1", summary: { mode: "custom" as const, instructions: "focus on tests" } };
|
||||||
@@ -592,6 +620,23 @@ function sessionInfoResponse(id: string) {
|
|||||||
return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
|
return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dialogStatusWire() {
|
||||||
|
return {
|
||||||
|
sessionId: "s /?",
|
||||||
|
isStreaming: true,
|
||||||
|
isCompacting: false,
|
||||||
|
isBashRunning: false,
|
||||||
|
pendingMessageCount: 0,
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
cost: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parsed status normalizes the wire shape (queuedMessages defaults to []).
|
||||||
|
function parsedDialogStatus() {
|
||||||
|
return { ...dialogStatusWire(), queuedMessages: [] };
|
||||||
|
}
|
||||||
|
|
||||||
function piWebConfigResponse(config: PiWebConfigValues) {
|
function piWebConfigResponse(config: PiWebConfigValues) {
|
||||||
return {
|
return {
|
||||||
path: "/tmp/pi-web/config.json",
|
path: "/tmp/pi-web/config.json",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AskUserSubmission, DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
import type { AskUserSubmission, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||||
import { resolveAppUrl } from "../appUrl";
|
import { resolveAppUrl } from "../appUrl";
|
||||||
import { request } from "./http";
|
import { request } from "./http";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
parseDeleted,
|
parseDeleted,
|
||||||
parseDeleteWorkspaceFileResponse,
|
parseDeleteWorkspaceFileResponse,
|
||||||
parseDetached,
|
parseDetached,
|
||||||
|
parseExtensionDialogCloseResponse,
|
||||||
parseFileContentResponse,
|
parseFileContentResponse,
|
||||||
parseFileSuggestion,
|
parseFileSuggestion,
|
||||||
parseFileTreeResponse,
|
parseFileTreeResponse,
|
||||||
@@ -227,6 +228,8 @@ export const sessionsApi = {
|
|||||||
dismissWarning: (session: SessionLookup, dismissId: string, machineId = "local") => request(sessionPath(session, "warnings/dismiss", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { dismissId }) }),
|
dismissWarning: (session: SessionLookup, dismissId: string, machineId = "local") => request(sessionPath(session, "warnings/dismiss", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { dismissId }) }),
|
||||||
submitAsk: (session: SessionLookup, askId: string, submission: AskUserSubmission, machineId = "local") => request(sessionPath(session, "ask/submit", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId, answers: submission.answers }) }),
|
submitAsk: (session: SessionLookup, askId: string, submission: AskUserSubmission, machineId = "local") => request(sessionPath(session, "ask/submit", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId, answers: submission.answers }) }),
|
||||||
cancelAsk: (session: SessionLookup, askId: string, machineId = "local") => request(sessionPath(session, "ask/cancel", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId }) }),
|
cancelAsk: (session: SessionLookup, askId: string, machineId = "local") => request(sessionPath(session, "ask/cancel", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId }) }),
|
||||||
|
answerDialog: (session: SessionLookup, dialogId: string, value: ExtensionDialogAnswer, machineId = "local") => request(sessionPath(session, "dialogs/answer", machineId), parseExtensionDialogCloseResponse, { method: "POST", body: sessionBody(session, { dialogId, value }) }),
|
||||||
|
cancelDialog: (session: SessionLookup, dialogId: string, machineId = "local") => request(sessionPath(session, "dialogs/cancel", machineId), parseExtensionDialogCloseResponse, { method: "POST", body: sessionBody(session, { dialogId }) }),
|
||||||
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
|
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
|
||||||
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
||||||
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ describe("federated route contract", () => {
|
|||||||
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("ask"))).toBe(false);
|
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("ask"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allowlists both extension dialog routes on the existing session WebSocket", () => {
|
||||||
|
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("/dialogs/"))).toEqual([
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/dialogs/answer" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/dialogs/cancel" },
|
||||||
|
]);
|
||||||
|
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("dialogs"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("allowlists daemon-authoritative unread HTTP routes on the existing global socket", () => {
|
it("allowlists daemon-authoritative unread HTTP routes on the existing global socket", () => {
|
||||||
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("unread"))).toEqual([
|
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("unread"))).toEqual([
|
||||||
{ method: "GET", path: "/sessions/unread" },
|
{ method: "GET", path: "/sessions/unread" },
|
||||||
@@ -107,6 +115,8 @@ describe("federated route contract", () => {
|
|||||||
ignoreParseFailure(sessionsApi.dismissWarning(session, "anthropicExtraUsage", machineId)),
|
ignoreParseFailure(sessionsApi.dismissWarning(session, "anthropicExtraUsage", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.submitAsk(session, "ask 1", { answers: [{ id: "q1", values: ["pg"] }] }, machineId)),
|
ignoreParseFailure(sessionsApi.submitAsk(session, "ask 1", { answers: [{ id: "q1", values: ["pg"] }] }, machineId)),
|
||||||
ignoreParseFailure(sessionsApi.cancelAsk(session, "ask 1", machineId)),
|
ignoreParseFailure(sessionsApi.cancelAsk(session, "ask 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.answerDialog(session, "dialog 1", true, machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.cancelDialog(session, "dialog 1", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.models(session, machineId)),
|
ignoreParseFailure(sessionsApi.models(session, machineId)),
|
||||||
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
|
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
|
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||||
import { ASK_USER_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes";
|
import { ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes";
|
||||||
import { parseAskUserCloseResponse, parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStartupProgressEvent, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
import { parseAskUserCloseResponse, parseAuthProvidersResponse, parseCommandResult, parseExtensionDialogCloseResponse, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStartupProgressEvent, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||||
|
|
||||||
describe("API parsers", () => {
|
describe("API parsers", () => {
|
||||||
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
|
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
|
||||||
@@ -796,6 +796,65 @@ describe("API parsers", () => {
|
|||||||
sessionStatus: statusWire(),
|
sessionStatus: statusWire(),
|
||||||
})).toThrow("Ask answer selected an option the question never offered");
|
})).toThrow("Ask answer selected an option the question never offered");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses open extension dialogs on the session status, oldest first", () => {
|
||||||
|
const parsed = parseSessionStatus({ ...statusWire(), pendingDialogs: [confirmDialogWire(), selectDialogWire(), inputDialogWire()] });
|
||||||
|
|
||||||
|
expect(parsed.pendingDialogs).toEqual([
|
||||||
|
{ dialogId: "dialog-1", kind: "confirm", title: "Delete the build cache?", message: "This cannot be undone", askedAt: "2026-07-20T00:00:00.000Z", runScoped: true },
|
||||||
|
{ dialogId: "dialog-2", kind: "select", title: "Pick a database", options: ["Postgres", "SQLite"], askedAt: "2026-07-20T00:01:00.000Z", timeoutAt: "2026-07-20T00:06:00.000Z", runScoped: false },
|
||||||
|
{ dialogId: "dialog-3", kind: "input", title: "Name the branch", placeholder: "feature/...", askedAt: "2026-07-20T00:02:00.000Z", runScoped: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits pending dialogs entirely when the field is absent", () => {
|
||||||
|
expect(parseSessionStatus(statusWire()).pendingDialogs).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates an extension dialog before rendering it", () => {
|
||||||
|
const dialog = confirmDialogWire();
|
||||||
|
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, kind: "modal" }] })).toThrow("Invalid extension dialog kind");
|
||||||
|
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, title: "" }] })).toThrow("Expected non-empty string field: title");
|
||||||
|
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, title: "x".repeat(EXTENSION_DIALOG_TEXT_MAX_LENGTH + 1) }] })).toThrow("String field exceeds limit: title");
|
||||||
|
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, runScoped: "yes" }] })).toThrow("Expected boolean field: runScoped");
|
||||||
|
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, timeoutAt: "" }] })).toThrow("Expected non-empty string field: timeoutAt");
|
||||||
|
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...selectDialogWire(), options: [] }] })).toThrow("Select dialog has no options");
|
||||||
|
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...selectDialogWire(), options: ["a", "a"] }] })).toThrow("Duplicate dialog option");
|
||||||
|
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [dialog, { ...inputDialogWire(), dialogId: "dialog-1" }] })).toThrow("Duplicate dialog id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a closed dialog response carrying the outcome and recomputed status", () => {
|
||||||
|
const response = parseExtensionDialogCloseResponse({
|
||||||
|
result: "closed",
|
||||||
|
outcome: dialogOutcomeWire(),
|
||||||
|
sessionStatus: statusWire(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.result).toBe("closed");
|
||||||
|
expect(response.outcome).toEqual({
|
||||||
|
dialogId: "dialog-1",
|
||||||
|
reason: "answered",
|
||||||
|
answer: true,
|
||||||
|
askedAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
closedAt: "2026-07-20T00:01:00.000Z",
|
||||||
|
});
|
||||||
|
expect(response.sessionStatus.sessionId).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a stale dialog close as an ordinary race with no outcome", () => {
|
||||||
|
const response = parseExtensionDialogCloseResponse({ result: "stale", sessionStatus: statusWire() });
|
||||||
|
|
||||||
|
expect(response).toEqual({ result: "stale", sessionStatus: parseSessionStatus(statusWire()) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects dialog close responses whose outcome contradicts itself", () => {
|
||||||
|
const outcome = dialogOutcomeWire();
|
||||||
|
expect(() => parseExtensionDialogCloseResponse({ result: "closed", sessionStatus: statusWire() })).toThrow("Dialog close response outcome mismatch");
|
||||||
|
expect(() => parseExtensionDialogCloseResponse({ result: "stale", outcome, sessionStatus: statusWire() })).toThrow("Dialog close response outcome mismatch");
|
||||||
|
expect(() => parseExtensionDialogCloseResponse({ result: "closed", outcome: { ...outcome, reason: "timeout" }, sessionStatus: statusWire() })).toThrow("Dialog outcome answer mismatch");
|
||||||
|
expect(() => parseExtensionDialogCloseResponse({ result: "closed", outcome: { ...outcome, answer: 1 }, sessionStatus: statusWire() })).toThrow("Invalid extension dialog answer");
|
||||||
|
expect(() => parseExtensionDialogCloseResponse({ result: "closed", outcome: { ...outcome, reason: "ignored" }, sessionStatus: statusWire() })).toThrow("Invalid extension dialog close reason");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function statusWire() {
|
function statusWire() {
|
||||||
@@ -845,6 +904,50 @@ function askOutcomeWire() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDialogWire() {
|
||||||
|
return {
|
||||||
|
dialogId: "dialog-1",
|
||||||
|
kind: "confirm",
|
||||||
|
title: "Delete the build cache?",
|
||||||
|
message: "This cannot be undone",
|
||||||
|
askedAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
runScoped: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDialogWire() {
|
||||||
|
return {
|
||||||
|
dialogId: "dialog-2",
|
||||||
|
kind: "select",
|
||||||
|
title: "Pick a database",
|
||||||
|
options: ["Postgres", "SQLite"],
|
||||||
|
askedAt: "2026-07-20T00:01:00.000Z",
|
||||||
|
timeoutAt: "2026-07-20T00:06:00.000Z",
|
||||||
|
runScoped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputDialogWire() {
|
||||||
|
return {
|
||||||
|
dialogId: "dialog-3",
|
||||||
|
kind: "input",
|
||||||
|
title: "Name the branch",
|
||||||
|
placeholder: "feature/...",
|
||||||
|
askedAt: "2026-07-20T00:02:00.000Z",
|
||||||
|
runScoped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialogOutcomeWire() {
|
||||||
|
return {
|
||||||
|
dialogId: "dialog-1",
|
||||||
|
reason: "answered",
|
||||||
|
answer: true,
|
||||||
|
askedAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
closedAt: "2026-07-20T00:01:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sessionTreeWire() {
|
function sessionTreeWire() {
|
||||||
const kinds = [
|
const kinds = [
|
||||||
"user",
|
"user",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AskUserCloseReason, type AskUserCloseResponse, type AskUserOutcome, type AskUserQuestion, type AskUserQuestionOption, type AskUserQuestionRecord, type PendingAskUser, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, EXTENSION_DIALOG_OPTION_LIMIT, EXTENSION_DIALOG_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AskUserCloseReason, type AskUserCloseResponse, type AskUserOutcome, type AskUserQuestion, type AskUserQuestionOption, type AskUserQuestionRecord, type PendingAskUser, type PendingExtensionDialog, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogKind, type ExtensionDialogOutcome, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionActivity, SessionStartupProgressEvent, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
|
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionActivity, SessionStartupProgressEvent, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
|
||||||
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
|
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
|
||||||
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
||||||
@@ -332,10 +332,126 @@ export function parseAskUserCloseResponse(value: unknown): AskUserCloseResponse
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseExtensionDialogCloseResponse(value: unknown): ExtensionDialogCloseResponse {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const result = record["result"];
|
||||||
|
if (result !== "closed" && result !== "stale") throw new Error("Invalid dialog close result");
|
||||||
|
const outcome = record["outcome"] === undefined ? undefined : parseExtensionDialogOutcome(record["outcome"]);
|
||||||
|
// Only the call that actually closed the dialog carries an outcome; a stale
|
||||||
|
// close reports none and is trusted for the session status alone.
|
||||||
|
if ((result === "closed") !== (outcome !== undefined)) throw new Error("Dialog close response outcome mismatch");
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
...(outcome === undefined ? {} : { outcome }),
|
||||||
|
sessionStatus: parseSessionStatus(record["sessionStatus"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function assertUniqueStrings(values: readonly string[], label: string): void {
|
function assertUniqueStrings(values: readonly string[], label: string): void {
|
||||||
if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`);
|
if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseExtensionDialogKind(value: unknown): ExtensionDialogKind {
|
||||||
|
if (value !== "confirm" && value !== "select" && value !== "input") throw new Error("Invalid extension dialog kind");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExtensionDialogCloseReason(value: unknown): ExtensionDialogCloseReason {
|
||||||
|
if (value !== "answered" && value !== "cancelled" && value !== "timeout" && value !== "aborted" && value !== "session-ended") {
|
||||||
|
throw new Error("Invalid extension dialog close reason");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExtensionDialogAnswer(value: unknown): ExtensionDialogAnswer {
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "string" && value.length <= EXTENSION_DIALOG_INPUT_MAX_LENGTH) return value;
|
||||||
|
throw new Error("Invalid extension dialog answer");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExtensionDialogOption(value: unknown): string {
|
||||||
|
const option = parseNonEmptyString(value);
|
||||||
|
if (option.length > EXTENSION_DIALOG_TEXT_MAX_LENGTH) throw new Error("String field exceeds limit: option");
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate one open extension dialog. A malformed dialog must be dropped rather
|
||||||
|
* than rendered: the card parks an extension's blocking wait on the user's
|
||||||
|
* answer, so a choice list or prompt the daemon did not really send must never
|
||||||
|
* appear.
|
||||||
|
*/
|
||||||
|
function parsePendingExtensionDialog(value: unknown): PendingExtensionDialog {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const kind = parseExtensionDialogKind(record["kind"]);
|
||||||
|
const options = record["options"] === undefined
|
||||||
|
? undefined
|
||||||
|
: boundedArrayOf(record["options"], parseExtensionDialogOption, EXTENSION_DIALOG_OPTION_LIMIT, "options");
|
||||||
|
if (options !== undefined) assertUniqueStrings(options, "dialog option");
|
||||||
|
if (kind === "select" && (options === undefined || options.length === 0)) throw new Error("Select dialog has no options");
|
||||||
|
return {
|
||||||
|
dialogId: requireBoundedNonEmptyString(record, "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH),
|
||||||
|
kind,
|
||||||
|
title: requireBoundedNonEmptyString(record, "title", EXTENSION_DIALOG_TEXT_MAX_LENGTH),
|
||||||
|
...optionalField("message", optionalBoundedNonEmptyString(record, "message", EXTENSION_DIALOG_TEXT_MAX_LENGTH)),
|
||||||
|
...(options === undefined ? {} : { options }),
|
||||||
|
...optionalField("placeholder", optionalBoundedNonEmptyString(record, "placeholder", EXTENSION_DIALOG_TEXT_MAX_LENGTH)),
|
||||||
|
askedAt: requireNonEmptyString(record, "askedAt"),
|
||||||
|
...optionalField("timeoutAt", optionalNonEmptyString(record, "timeoutAt")),
|
||||||
|
runScoped: requireBoolean(record, "runScoped"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalPendingDialogs(value: unknown): Pick<SessionStatus, "pendingDialogs"> | object {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
const dialogs = arrayOf(parsePendingExtensionDialog)(value);
|
||||||
|
assertUniqueStrings(dialogs.map((dialog) => dialog.dialogId), "dialog id");
|
||||||
|
return { pendingDialogs: dialogs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionDialogOpenedEvent(value: unknown): { type: "dialog.opened"; dialog: PendingExtensionDialog } {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
if (record["type"] !== "dialog.opened") throw new Error("Invalid dialog opened event type");
|
||||||
|
return { type: "dialog.opened", dialog: parsePendingExtensionDialog(record["dialog"]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionDialogClosedEvent(value: unknown): { type: "dialog.closed"; dialogId: string; reason: ExtensionDialogCloseReason; answer?: ExtensionDialogAnswer } {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
if (record["type"] !== "dialog.closed") throw new Error("Invalid dialog closed event type");
|
||||||
|
const reason = parseExtensionDialogCloseReason(record["reason"]);
|
||||||
|
const answer = record["answer"] === undefined ? undefined : parseExtensionDialogAnswer(record["answer"]);
|
||||||
|
// Only an answered close carries a value; any other combination cannot be
|
||||||
|
// rendered honestly as the dialog's result.
|
||||||
|
if ((reason === "answered") !== (answer !== undefined)) throw new Error("Dialog closed event answer mismatch");
|
||||||
|
return {
|
||||||
|
type: "dialog.closed",
|
||||||
|
dialogId: requireBoundedNonEmptyString(record, "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH),
|
||||||
|
reason,
|
||||||
|
...(answer === undefined ? {} : { answer }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseExtensionDialogOutcome(value: unknown): ExtensionDialogOutcome {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const reason = parseExtensionDialogCloseReason(record["reason"]);
|
||||||
|
const answer = record["answer"] === undefined ? undefined : parseExtensionDialogAnswer(record["answer"]);
|
||||||
|
if ((reason === "answered") !== (answer !== undefined)) throw new Error("Dialog outcome answer mismatch");
|
||||||
|
return {
|
||||||
|
dialogId: requireBoundedNonEmptyString(record, "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH),
|
||||||
|
reason,
|
||||||
|
...(answer === undefined ? {} : { answer }),
|
||||||
|
askedAt: requireNonEmptyString(record, "askedAt"),
|
||||||
|
closedAt: requireNonEmptyString(record, "closedAt"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNonEmptyString(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = optionalString(record, key);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value === "") throw new Error(`Expected non-empty string field: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSessionStatus(value: unknown): SessionStatus {
|
export function parseSessionStatus(value: unknown): SessionStatus {
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
return {
|
return {
|
||||||
@@ -354,6 +470,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
|
|||||||
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
|
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
|
||||||
...optionalWarnings(record["warnings"]),
|
...optionalWarnings(record["warnings"]),
|
||||||
...optionalPendingAsk(record["pendingAsk"]),
|
...optionalPendingAsk(record["pendingAsk"]),
|
||||||
|
...optionalPendingDialogs(record["pendingDialogs"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PendingAskUser, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
import type { AuthProviderOption, CommandOption, CommandResult, ExtensionDialogAnswer, ExtensionDialogCloseReason, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PendingAskUser, PendingExtensionDialog, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||||
import type { ChatLine } from "./components/shared";
|
import type { ChatLine } from "./components/shared";
|
||||||
import type { QualifiedContributionId } from "./plugins/ids";
|
import type { QualifiedContributionId } from "./plugins/ids";
|
||||||
import type { SelectedSessionNotificationInbox } from "./sessionNotifications";
|
import type { SelectedSessionNotificationInbox } from "./sessionNotifications";
|
||||||
@@ -37,6 +37,20 @@ export interface AppState {
|
|||||||
* dropped when the machine reports no `sessions.askUser` support.
|
* dropped when the machine reports no `sessions.askUser` support.
|
||||||
*/
|
*/
|
||||||
pendingAsk: PendingAskUser | undefined;
|
pendingAsk: PendingAskUser | undefined;
|
||||||
|
/**
|
||||||
|
* The selected session's open extension dialogs, derived from the
|
||||||
|
* daemon-owned {@link SessionStatus.pendingDialogs} plus live dialog events.
|
||||||
|
* Oldest first; unlike an ask, opening never supersedes, so several dialogs
|
||||||
|
* may wait at once.
|
||||||
|
*/
|
||||||
|
pendingDialogs: PendingExtensionDialog[];
|
||||||
|
/**
|
||||||
|
* Dialogs that closed while their session was selected, kept with the close
|
||||||
|
* reason and any answer so the card can render its outcome briefly. The wire
|
||||||
|
* outcome is deliberately small, so only a browser that saw the dialog open
|
||||||
|
* can show the closed card; deselection and reloads drop these.
|
||||||
|
*/
|
||||||
|
closedDialogs: ClosedExtensionDialog[];
|
||||||
/** Thinking levels available for the selected session's current model. */
|
/** Thinking levels available for the selected session's current model. */
|
||||||
availableThinkingLevels: readonly string[];
|
availableThinkingLevels: readonly string[];
|
||||||
sessionStatuses: Record<string, SessionStatus>;
|
sessionStatuses: Record<string, SessionStatus>;
|
||||||
@@ -76,6 +90,14 @@ export interface AppState {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A closed extension dialog paired with the record the browser rendered while it was open. */
|
||||||
|
export interface ClosedExtensionDialog {
|
||||||
|
dialog: PendingExtensionDialog;
|
||||||
|
reason: ExtensionDialogCloseReason;
|
||||||
|
/** Present only when `reason` is `"answered"`. */
|
||||||
|
answer?: ExtensionDialogAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
export type AuthDialogState =
|
export type AuthDialogState =
|
||||||
| { step: "method" }
|
| { step: "method" }
|
||||||
| { step: "providers"; mode: "login"; authType?: "oauth" | "api_key"; providers: AuthProviderOption[] }
|
| { step: "providers"; mode: "login"; authType?: "oauth" | "api_key"; providers: AuthProviderOption[] }
|
||||||
@@ -151,6 +173,8 @@ export function initialAppState(): AppState {
|
|||||||
status: undefined,
|
status: undefined,
|
||||||
activity: undefined,
|
activity: undefined,
|
||||||
pendingAsk: undefined,
|
pendingAsk: undefined,
|
||||||
|
pendingDialogs: [],
|
||||||
|
closedDialogs: [],
|
||||||
availableThinkingLevels: [],
|
availableThinkingLevels: [],
|
||||||
sessionStatuses: {},
|
sessionStatuses: {},
|
||||||
sessionActivities: {},
|
sessionActivities: {},
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { initialAppState } from "../appState";
|
||||||
|
import type { ExtensionDialogCloseResponse, ExtensionDialogKind, PendingExtensionDialog } from "../api";
|
||||||
|
import { SessionController } from "./sessionController";
|
||||||
|
import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, status, workspace, type AppState, type SessionStatus } from "./sessionController.testSupport";
|
||||||
|
|
||||||
|
function dialog(dialogId: string, kind: ExtensionDialogKind = "confirm"): PendingExtensionDialog {
|
||||||
|
return {
|
||||||
|
dialogId,
|
||||||
|
kind,
|
||||||
|
title: `Dialog ${dialogId}`,
|
||||||
|
...(kind === "confirm" ? { message: "Are you sure?" } : {}),
|
||||||
|
...(kind === "select" ? { options: ["Postgres", "SQLite"] } : {}),
|
||||||
|
...(kind === "input" ? { placeholder: "type here" } : {}),
|
||||||
|
askedAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
runScoped: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusWithDialogs(sessionId: string, pendingDialogs: PendingExtensionDialog[]): SessionStatus {
|
||||||
|
return { ...status(sessionId), pendingDialogs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeResponse(sessionStatus: SessionStatus, dialogId = "dialog-1"): ExtensionDialogCloseResponse {
|
||||||
|
return {
|
||||||
|
result: "closed",
|
||||||
|
outcome: {
|
||||||
|
dialogId,
|
||||||
|
reason: "answered",
|
||||||
|
answer: true,
|
||||||
|
askedAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
closedAt: "2026-07-20T00:01:00.000Z",
|
||||||
|
},
|
||||||
|
sessionStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedState(patch: Partial<AppState> = {}): AppState {
|
||||||
|
return {
|
||||||
|
...initialAppState(),
|
||||||
|
selectedWorkspace: workspace,
|
||||||
|
selectedSession: oldSession,
|
||||||
|
sessions: [oldSession],
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectableApi(sessionStatus: SessionStatus): typeof defaultApi {
|
||||||
|
return {
|
||||||
|
...defaultApi,
|
||||||
|
messages: () => Promise.resolve(emptyPage),
|
||||||
|
status: () => Promise.resolve(sessionStatus),
|
||||||
|
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||||
|
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveHarness {
|
||||||
|
controller: SessionController;
|
||||||
|
socket: EmitSocket;
|
||||||
|
state: () => AppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function liveSession(patch: Partial<AppState> = {}, sessionStatus = status(oldSession.id)): Promise<LiveHarness> {
|
||||||
|
const socket = new EmitSocket();
|
||||||
|
let state = selectedState({ selectedSession: undefined, ...patch });
|
||||||
|
const controller = new SessionController(
|
||||||
|
() => state,
|
||||||
|
(statePatch) => { state = { ...state, ...statePatch }; },
|
||||||
|
() => undefined,
|
||||||
|
undefined,
|
||||||
|
{ api: selectableApi(sessionStatus), socket },
|
||||||
|
);
|
||||||
|
await controller.selectSession(oldSession, { updateUrl: false });
|
||||||
|
return { controller, socket, state: () => state };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SessionController extension dialog state", () => {
|
||||||
|
it("rehydrates open dialogs from the daemon-owned status on selection", async () => {
|
||||||
|
const pending = [dialog("dialog-1"), dialog("dialog-2", "select")];
|
||||||
|
|
||||||
|
const harness = await liveSession({}, statusWithDialogs(oldSession.id, pending));
|
||||||
|
|
||||||
|
expect(harness.state().pendingDialogs).toEqual(pending);
|
||||||
|
expect(harness.state().closedDialogs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens and closes cards from live dialog events without superseding other dialogs", async () => {
|
||||||
|
const harness = await liveSession();
|
||||||
|
|
||||||
|
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
|
||||||
|
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2", "input") });
|
||||||
|
expect(harness.state().pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-1", "dialog-2"]);
|
||||||
|
|
||||||
|
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: true });
|
||||||
|
expect(harness.state().pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the closed dialog's outcome so the card can render what happened", async () => {
|
||||||
|
const harness = await liveSession();
|
||||||
|
|
||||||
|
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1", "select") });
|
||||||
|
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: "SQLite" });
|
||||||
|
|
||||||
|
expect(harness.state().closedDialogs).toEqual([{ dialog: dialog("dialog-1", "select"), reason: "answered", answer: "SQLite" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a close without an answer for cancel-like reasons", async () => {
|
||||||
|
const harness = await liveSession();
|
||||||
|
|
||||||
|
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
|
||||||
|
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "aborted" });
|
||||||
|
|
||||||
|
expect(harness.state().closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "aborted" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a close for a dialog that is not on screen", async () => {
|
||||||
|
const harness = await liveSession();
|
||||||
|
|
||||||
|
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2") });
|
||||||
|
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" });
|
||||||
|
|
||||||
|
expect(harness.state().pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]);
|
||||||
|
expect(harness.state().closedDialogs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate a card when the open frame is already reflected", async () => {
|
||||||
|
const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1")]));
|
||||||
|
|
||||||
|
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
|
||||||
|
|
||||||
|
expect(harness.state().pendingDialogs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a status that no longer carries a dialog as the authoritative close", async () => {
|
||||||
|
const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1")]));
|
||||||
|
expect(harness.state().pendingDialogs).toHaveLength(1);
|
||||||
|
|
||||||
|
harness.controller.applySessionStatus(status(oldSession.id));
|
||||||
|
|
||||||
|
expect(harness.state().pendingDialogs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not adopt another session's open dialogs", async () => {
|
||||||
|
const harness = await liveSession();
|
||||||
|
|
||||||
|
harness.controller.applySessionStatus(statusWithDialogs("other-session", [dialog("dialog-1")]));
|
||||||
|
|
||||||
|
expect(harness.state().pendingDialogs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears open and closed dialogs when the session is deselected", async () => {
|
||||||
|
const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1"), dialog("dialog-2")]));
|
||||||
|
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" });
|
||||||
|
expect(harness.state().closedDialogs).toHaveLength(1);
|
||||||
|
|
||||||
|
harness.controller.deselectSession({ updateUrl: false });
|
||||||
|
|
||||||
|
expect(harness.state().pendingDialogs).toEqual([]);
|
||||||
|
expect(harness.state().closedDialogs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a closed dialog's outcome card when it is dismissed", async () => {
|
||||||
|
const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1")]));
|
||||||
|
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" });
|
||||||
|
expect(harness.state().closedDialogs).toHaveLength(1);
|
||||||
|
|
||||||
|
harness.controller.dismissClosedDialog("dialog-1");
|
||||||
|
|
||||||
|
expect(harness.state().closedDialogs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SessionController extension dialog answers", () => {
|
||||||
|
it("answers a dialog, records the outcome, and applies the returned status", async () => {
|
||||||
|
const answerCalls: { dialogId: string; value: unknown; machineId: string }[] = [];
|
||||||
|
const closedStatus = status(oldSession.id);
|
||||||
|
let state = selectedState({ status: statusWithDialogs(oldSession.id, [dialog("dialog-1")]), pendingDialogs: [dialog("dialog-1")] });
|
||||||
|
const api: typeof defaultApi = {
|
||||||
|
...defaultApi,
|
||||||
|
answerDialog: (_session, dialogId, value, machineId) => {
|
||||||
|
answerCalls.push({ dialogId, value, machineId: machineId ?? "local" });
|
||||||
|
return Promise.resolve(closeResponse(closedStatus));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const controller = new SessionController(
|
||||||
|
() => state,
|
||||||
|
(patch) => { state = { ...state, ...patch }; },
|
||||||
|
() => undefined,
|
||||||
|
undefined,
|
||||||
|
{ api, socket: new FakeSocket() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.answerDialog("dialog-1", true);
|
||||||
|
|
||||||
|
expect(answerCalls).toEqual([{ dialogId: "dialog-1", value: true, machineId: "local" }]);
|
||||||
|
expect(state.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "answered", answer: true }]);
|
||||||
|
expect(state.pendingDialogs).toEqual([]);
|
||||||
|
expect(state.status).toEqual(closedStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a dialog through its own route", async () => {
|
||||||
|
const cancelCalls: string[] = [];
|
||||||
|
let state = selectedState({ pendingDialogs: [dialog("dialog-1")] });
|
||||||
|
const api: typeof defaultApi = {
|
||||||
|
...defaultApi,
|
||||||
|
cancelDialog: (_session, dialogId) => {
|
||||||
|
cancelCalls.push(dialogId);
|
||||||
|
return Promise.resolve({
|
||||||
|
result: "closed" as const,
|
||||||
|
outcome: { dialogId, reason: "cancelled" as const, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" },
|
||||||
|
sessionStatus: status(oldSession.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const controller = new SessionController(
|
||||||
|
() => state,
|
||||||
|
(patch) => { state = { ...state, ...patch }; },
|
||||||
|
() => undefined,
|
||||||
|
undefined,
|
||||||
|
{ api, socket: new FakeSocket() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.cancelDialog("dialog-1");
|
||||||
|
|
||||||
|
expect(cancelCalls).toEqual(["dialog-1"]);
|
||||||
|
expect(state.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "cancelled" }]);
|
||||||
|
expect(state.pendingDialogs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trusts the status of a stale close without an error or an outcome card", async () => {
|
||||||
|
let state = selectedState({ pendingDialogs: [dialog("dialog-1")] });
|
||||||
|
const api: typeof defaultApi = {
|
||||||
|
...defaultApi,
|
||||||
|
answerDialog: () => Promise.resolve({ result: "stale", sessionStatus: statusWithDialogs(oldSession.id, [dialog("dialog-2")]) }),
|
||||||
|
};
|
||||||
|
const controller = new SessionController(
|
||||||
|
() => state,
|
||||||
|
(patch) => { state = { ...state, ...patch }; },
|
||||||
|
() => undefined,
|
||||||
|
undefined,
|
||||||
|
{ api, socket: new FakeSocket() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.answerDialog("dialog-1", true);
|
||||||
|
|
||||||
|
expect(state.error).toBe("");
|
||||||
|
expect(state.closedDialogs).toEqual([]);
|
||||||
|
expect(state.pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the dialog open and reports the failure when the answer request fails", async () => {
|
||||||
|
let state = selectedState({ pendingDialogs: [dialog("dialog-1")] });
|
||||||
|
const api: typeof defaultApi = { ...defaultApi, answerDialog: () => Promise.reject(new Error("answer failed")) };
|
||||||
|
const controller = new SessionController(
|
||||||
|
() => state,
|
||||||
|
(patch) => { state = { ...state, ...patch }; },
|
||||||
|
() => undefined,
|
||||||
|
undefined,
|
||||||
|
{ api, socket: new FakeSocket() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.answerDialog("dialog-1", true);
|
||||||
|
|
||||||
|
expect(state.error).toBe("Error: answer failed");
|
||||||
|
expect(state.pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-1"]);
|
||||||
|
expect(state.closedDialogs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not answer for an archived session", async () => {
|
||||||
|
const archived = { ...oldSession, archived: true as const };
|
||||||
|
let state = selectedState({ selectedSession: archived, sessions: [archived] });
|
||||||
|
let answered = false;
|
||||||
|
const api: typeof defaultApi = {
|
||||||
|
...defaultApi,
|
||||||
|
answerDialog: () => {
|
||||||
|
answered = true;
|
||||||
|
return Promise.resolve(closeResponse(status(oldSession.id)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const controller = new SessionController(
|
||||||
|
() => state,
|
||||||
|
(patch) => { state = { ...state, ...patch }; },
|
||||||
|
() => undefined,
|
||||||
|
undefined,
|
||||||
|
{ api, socket: new FakeSocket() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.answerDialog("dialog-1", true);
|
||||||
|
|
||||||
|
expect(answered).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api as defaultApi, type AskUserCloseResponse, type AskUserSubmission, type CommandResult, type PendingAskUser, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type Workspace } from "../api";
|
import { api as defaultApi, type AskUserCloseResponse, type AskUserSubmission, type CommandResult, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogOutcome, type PendingAskUser, type PendingExtensionDialog, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type Workspace } from "../api";
|
||||||
import type { AppState } from "../appState";
|
import type { AppState, ClosedExtensionDialog } from "../appState";
|
||||||
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
||||||
import { textMessage } from "../chatMessages";
|
import { textMessage } from "../chatMessages";
|
||||||
import { machineSessionKey } from "../machineKeys";
|
import { machineSessionKey } from "../machineKeys";
|
||||||
@@ -162,7 +162,7 @@ export class SessionController {
|
|||||||
// session must not cancel the in-flight upload indicator of the session
|
// session must not cancel the in-flight upload indicator of the session
|
||||||
// that is still sending; the per-session entry is cleared by send()'s
|
// that is still sending; the per-session entry is cleared by send()'s
|
||||||
// finally block when the request settles.
|
// finally block when the request settles.
|
||||||
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, availableThinkingLevels: [], treeDialog: undefined });
|
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, pendingDialogs: [], closedDialogs: [], availableThinkingLevels: [], treeDialog: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
|
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
|
||||||
@@ -221,6 +221,8 @@ export class SessionController {
|
|||||||
status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id],
|
status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id],
|
||||||
activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id],
|
activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id],
|
||||||
pendingAsk: session.archived === true ? undefined : this.selectedPendingAsk(this.getState().sessionStatuses[session.id], machineId),
|
pendingAsk: session.archived === true ? undefined : this.selectedPendingAsk(this.getState().sessionStatuses[session.id], machineId),
|
||||||
|
pendingDialogs: session.archived === true ? [] : (this.getState().sessionStatuses[session.id]?.pendingDialogs ?? []),
|
||||||
|
closedDialogs: [],
|
||||||
availableThinkingLevels: [],
|
availableThinkingLevels: [],
|
||||||
});
|
});
|
||||||
let buffered: SessionUiEvent[] | undefined;
|
let buffered: SessionUiEvent[] | undefined;
|
||||||
@@ -229,7 +231,7 @@ export class SessionController {
|
|||||||
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||||
const history = this.transcripts.mergeHistory(transcriptKey, page);
|
const history = this.transcripts.mergeHistory(transcriptKey, page);
|
||||||
this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined });
|
this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, pendingDialogs: [], closedDialogs: [] });
|
||||||
this.onSelectedSessionReady?.({ machineId, session });
|
this.onSelectedSessionReady?.({ machineId, session });
|
||||||
if (options?.updateUrl !== false) this.updateUrl();
|
if (options?.updateUrl !== false) this.updateUrl();
|
||||||
return;
|
return;
|
||||||
@@ -666,7 +668,7 @@ export class SessionController {
|
|||||||
sessions: nextSessions,
|
sessions: nextSessions,
|
||||||
sessionStatuses: omitKeys(state.sessionStatuses, affectedIds),
|
sessionStatuses: omitKeys(state.sessionStatuses, affectedIds),
|
||||||
sessionActivities: omitKeys(state.sessionActivities, affectedIds),
|
sessionActivities: omitKeys(state.sessionActivities, affectedIds),
|
||||||
...(selectedAffected ? { status: undefined, activity: undefined, pendingAsk: undefined } : {}),
|
...(selectedAffected ? { status: undefined, activity: undefined, pendingAsk: undefined, pendingDialogs: [], closedDialogs: [] } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
|
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
|
||||||
@@ -897,6 +899,41 @@ export class SessionController {
|
|||||||
return this.closeOpenAsk(askId, (session, machineId) => this.api.cancelAsk(session, askId, machineId));
|
return this.closeOpenAsk(askId, (session, machineId) => this.api.cancelAsk(session, askId, machineId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Send the value the user gave for one of the session's open extension dialogs. */
|
||||||
|
answerDialog(dialogId: string, value: ExtensionDialogAnswer): Promise<void> {
|
||||||
|
return this.closeOpenDialog(dialogId, (session, machineId) => this.api.answerDialog(session, dialogId, value, machineId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close one of the session's open extension dialogs without answering it. */
|
||||||
|
cancelDialog(dialogId: string): Promise<void> {
|
||||||
|
return this.closeOpenDialog(dialogId, (session, machineId) => this.api.cancelDialog(session, dialogId, machineId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeOpenDialog(dialogId: string, close: (session: SessionInfo, machineId: string) => Promise<ExtensionDialogCloseResponse>): Promise<void> {
|
||||||
|
const state = this.getState();
|
||||||
|
const session = state.selectedSession;
|
||||||
|
if (session === undefined || session.archived === true || isClientPendingStartSessionInfo(session)) return;
|
||||||
|
const machineId = selectedMachineId(state);
|
||||||
|
const selectionSeq = this.selectionSeq;
|
||||||
|
try {
|
||||||
|
const response = await close(session, machineId);
|
||||||
|
if (!this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) return;
|
||||||
|
// When this call closed the dialog, its outcome is recorded right away so
|
||||||
|
// the card shows what the user gave; the daemon's dialog.closed event
|
||||||
|
// then finds the dialog already closed here and stays a no-op.
|
||||||
|
const outcome: ExtensionDialogOutcome | undefined = response.outcome;
|
||||||
|
if (outcome !== undefined) {
|
||||||
|
const dialog = this.getState().pendingDialogs.find((pending) => pending.dialogId === outcome.dialogId);
|
||||||
|
if (dialog !== undefined) this.recordClosedDialog({ dialog, reason: outcome.reason, ...(outcome.answer === undefined ? {} : { answer: outcome.answer }) });
|
||||||
|
}
|
||||||
|
// Both outcomes carry the recomputed status, so no follow-up status
|
||||||
|
// request is needed to learn what the session's open dialogs are now.
|
||||||
|
this.applyStatus(response.sessionStatus);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async closeOpenAsk(askId: string, close: (session: SessionInfo, machineId: string) => Promise<AskUserCloseResponse>): Promise<void> {
|
private async closeOpenAsk(askId: string, close: (session: SessionInfo, machineId: string) => Promise<AskUserCloseResponse>): Promise<void> {
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const session = state.selectedSession;
|
const session = state.selectedSession;
|
||||||
@@ -1076,6 +1113,8 @@ export class SessionController {
|
|||||||
status: undefined,
|
status: undefined,
|
||||||
activity,
|
activity,
|
||||||
pendingAsk: undefined,
|
pendingAsk: undefined,
|
||||||
|
pendingDialogs: [],
|
||||||
|
closedDialogs: [],
|
||||||
availableThinkingLevels: [],
|
availableThinkingLevels: [],
|
||||||
treeDialog: undefined,
|
treeDialog: undefined,
|
||||||
...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }),
|
...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }),
|
||||||
@@ -1116,7 +1155,7 @@ export class SessionController {
|
|||||||
sessionActivities: omitSessionActivity(state.sessionActivities, tempId),
|
sessionActivities: omitSessionActivity(state.sessionActivities, tempId),
|
||||||
sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id),
|
sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id),
|
||||||
clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id),
|
clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id),
|
||||||
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id], pendingAsk: this.selectedPendingAsk(state.sessionStatuses[cachedSession.id], pending.machineId) } : {}),
|
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id], pendingAsk: this.selectedPendingAsk(state.sessionStatuses[cachedSession.id], pending.machineId), pendingDialogs: state.sessionStatuses[cachedSession.id]?.pendingDialogs ?? [], closedDialogs: [] } : {}),
|
||||||
error: "",
|
error: "",
|
||||||
});
|
});
|
||||||
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
|
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
|
||||||
@@ -1274,9 +1313,48 @@ export class SessionController {
|
|||||||
// The daemon owns whether an ask is open, so every status it publishes is
|
// The daemon owns whether an ask is open, so every status it publishes is
|
||||||
// authoritative for the selected session's card, including its removal.
|
// authoritative for the selected session's card, including its removal.
|
||||||
...(isSelected ? { pendingAsk: this.selectedPendingAsk(status, selectedMachineId(state)) } : {}),
|
...(isSelected ? { pendingAsk: this.selectedPendingAsk(status, selectedMachineId(state)) } : {}),
|
||||||
|
// Same for extension dialogs: the status projection is authoritative for
|
||||||
|
// the open list. Closed-card outcomes are event/response-driven instead,
|
||||||
|
// so a status without the dialog simply drops it from the open list.
|
||||||
|
...(isSelected ? { pendingDialogs: status.pendingDialogs ?? [] } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyOpenedDialog(dialog: PendingExtensionDialog): void {
|
||||||
|
const state = this.getState();
|
||||||
|
if (state.selectedSession === undefined) return;
|
||||||
|
// Events apply exactly once, so an id already on screen means this frame
|
||||||
|
// was already reflected (e.g. a rehydrated open) and must not duplicate
|
||||||
|
// the card.
|
||||||
|
if (state.pendingDialogs.some((pending) => pending.dialogId === dialog.dialogId)) return;
|
||||||
|
this.setState({ pendingDialogs: [...state.pendingDialogs, dialog] });
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyClosedDialog(dialogId: string, reason: ExtensionDialogCloseReason, answer: ExtensionDialogAnswer | undefined): void {
|
||||||
|
// A close for a dialog that is not on screen is already reflected here
|
||||||
|
// (e.g. the answering browser's own response landed first), so it must not
|
||||||
|
// clear or duplicate other cards.
|
||||||
|
const dialog = this.getState().pendingDialogs.find((pending) => pending.dialogId === dialogId);
|
||||||
|
if (dialog === undefined) return;
|
||||||
|
this.recordClosedDialog({ dialog, reason, ...(answer === undefined ? {} : { answer }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordClosedDialog(closed: ClosedExtensionDialog): void {
|
||||||
|
const state = this.getState();
|
||||||
|
if (state.closedDialogs.some((entry) => entry.dialog.dialogId === closed.dialog.dialogId)) return;
|
||||||
|
this.setState({
|
||||||
|
pendingDialogs: state.pendingDialogs.filter((pending) => pending.dialogId !== closed.dialog.dialogId),
|
||||||
|
closedDialogs: [...state.closedDialogs, closed],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a closed dialog's transient outcome card (e.g. the user dismissed it). */
|
||||||
|
dismissClosedDialog(dialogId: string): void {
|
||||||
|
const state = this.getState();
|
||||||
|
if (!state.closedDialogs.some((entry) => entry.dialog.dialogId === dialogId)) return;
|
||||||
|
this.setState({ closedDialogs: state.closedDialogs.filter((entry) => entry.dialog.dialogId !== dialogId) });
|
||||||
|
}
|
||||||
|
|
||||||
private applyOpenedAsk(ask: PendingAskUser): void {
|
private applyOpenedAsk(ask: PendingAskUser): void {
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
if (state.selectedSession === undefined) return;
|
if (state.selectedSession === undefined) return;
|
||||||
@@ -1368,6 +1446,14 @@ export class SessionController {
|
|||||||
this.applyClosedAsk(event.askId);
|
this.applyClosedAsk(event.askId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.type === "dialog.opened") {
|
||||||
|
this.applyOpenedDialog(event.dialog);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === "dialog.closed") {
|
||||||
|
this.applyClosedDialog(event.dialogId, event.reason, event.answer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event);
|
const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event);
|
||||||
if (transcript) {
|
if (transcript) {
|
||||||
this.setState({ messages: transcript });
|
this.setState({ messages: transcript });
|
||||||
|
|||||||
@@ -131,6 +131,31 @@ describe("notification socket guards", () => {
|
|||||||
expect(parseRealtimeSocketEvent({ type: "ask.opened", ask })).toBeUndefined();
|
expect(parseRealtimeSocketEvent({ type: "ask.opened", ask })).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts validated dialog frames and drops malformed ones", () => {
|
||||||
|
const dialog = {
|
||||||
|
dialogId: "dialog-1",
|
||||||
|
kind: "select",
|
||||||
|
title: "Pick a database",
|
||||||
|
options: ["Postgres", "SQLite"],
|
||||||
|
askedAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
runScoped: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parseSessionSocketEvent({ type: "dialog.opened", dialog })).toEqual({ type: "dialog.opened", dialog });
|
||||||
|
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: "SQLite" }))
|
||||||
|
.toEqual({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: "SQLite" });
|
||||||
|
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" }))
|
||||||
|
.toEqual({ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" });
|
||||||
|
expect(parseSessionSocketEvent({ type: "dialog.opened", dialog: { ...dialog, kind: "modal" } })).toBeUndefined();
|
||||||
|
expect(parseSessionSocketEvent({ type: "dialog.opened" })).toBeUndefined();
|
||||||
|
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "ignored" })).toBeUndefined();
|
||||||
|
// A close whose reason disagrees with its answer cannot be rendered honestly.
|
||||||
|
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered" })).toBeUndefined();
|
||||||
|
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled", answer: true })).toBeUndefined();
|
||||||
|
// Dialog frames are per-session only, so they must not be accepted globally.
|
||||||
|
expect(parseRealtimeSocketEvent({ type: "dialog.opened", dialog })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
|
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
|
||||||
expect(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" });
|
expect(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" });
|
||||||
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
|
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { realtimeEvents, sessionEvents } from "./api";
|
import { realtimeEvents, sessionEvents } from "./api";
|
||||||
import { parseSessionAskClosedEvent, parseSessionAskOpenedEvent, parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers";
|
import { parseSessionAskClosedEvent, parseSessionAskOpenedEvent, parseSessionDialogClosedEvent, parseSessionDialogOpenedEvent, parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers";
|
||||||
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
|
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
|
||||||
|
|
||||||
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||||
@@ -159,6 +159,10 @@ export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefi
|
|||||||
// so they are validated rather than accepted on their type alone.
|
// so they are validated rather than accepted on their type alone.
|
||||||
if (type === "ask.opened") return safelyParseValidatedEvent(() => parseSessionAskOpenedEvent(event));
|
if (type === "ask.opened") return safelyParseValidatedEvent(() => parseSessionAskOpenedEvent(event));
|
||||||
if (type === "ask.closed") return safelyParseValidatedEvent(() => parseSessionAskClosedEvent(event));
|
if (type === "ask.closed") return safelyParseValidatedEvent(() => parseSessionAskClosedEvent(event));
|
||||||
|
// Dialog frames drive an interactive card the user answers on the extension's
|
||||||
|
// behalf, so they are validated rather than accepted on their type alone.
|
||||||
|
if (type === "dialog.opened") return safelyParseValidatedEvent(() => parseSessionDialogOpenedEvent(event));
|
||||||
|
if (type === "dialog.closed") return safelyParseValidatedEvent(() => parseSessionDialogClosedEvent(event));
|
||||||
return isLegacySessionUiEvent(event) ? event : undefined;
|
return isLegacySessionUiEvent(event) ? event : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export const FEDERATED_HTTP_ROUTES = [
|
|||||||
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
|
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/ask/submit" },
|
{ method: "POST", path: "/sessions/:sessionId/ask/submit" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/ask/cancel" },
|
{ method: "POST", path: "/sessions/:sessionId/ask/cancel" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/dialogs/answer" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/dialogs/cancel" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/warnings/dismiss" },
|
{ method: "POST", path: "/sessions/:sessionId/warnings/dismiss" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/attachments" },
|
{ method: "POST", path: "/sessions/:sessionId/attachments" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
||||||
|
|||||||
Reference in New Issue
Block a user