From 60fa12c4de17be6ec87be6ed3bce980077b697f2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:57:41 +0200 Subject: [PATCH] Report disconnected hosts in new agent drafts (#7303) * fix(app): report offline hosts in new agent drafts A disconnected client still exists, and pending form resolution and retained loading catalogs outlive the connection. Use host connection state for draft readiness and model loading while keeping known models available for reconnect. Co-authored-by: Ivan Malison * test(app): confirm draft recovery on a fresh host connection --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Ivan Malison --- .../app/e2e/browser/offline-draft.spec.ts | 32 +++++++++++++++++++ .../src/composer/draft/workspace-tab-core.ts | 6 ++-- .../src/composer/draft/workspace-tab.test.ts | 16 +++++++++- .../app/src/composer/draft/workspace-tab.tsx | 5 +-- .../app/src/hooks/use-agent-form-state.ts | 10 ++++-- .../provider-selection.test.ts | 21 ++++++++++-- .../provider-selection/provider-selection.ts | 16 ++++++---- 7 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 packages/app/e2e/browser/offline-draft.spec.ts diff --git a/packages/app/e2e/browser/offline-draft.spec.ts b/packages/app/e2e/browser/offline-draft.spec.ts new file mode 100644 index 0000000000..36ae726457 --- /dev/null +++ b/packages/app/e2e/browser/offline-draft.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "../support/fixtures"; +import { expectComposerModel } from "../support/helpers/agent-profiles"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs"; +import { submitMessage } from "../support/helpers/composer"; + +async function restoreHostConnection(gate: Awaited>) { + gate.holdNextServerMessage("status"); + gate.restore(); + await gate.waitForHeldServerMessage("status"); + gate.releaseHeldServerMessage("status"); +} + +test("an offline workspace draft reports the disconnected host instead of loading models", async ({ + page, + withWorkspace, +}) => { + const gate = await installDaemonWebSocketGate(page); + const workspace = await withWorkspace(); + await workspace.navigateTo(); + await gate.drop(); + await gate.waitForBlockedConnection(); + await createAgentTabFromMenu(page); + await submitMessage(page, "hello"); + await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveText( + "Host is not connected", + ); + await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0); + await restoreHostConnection(gate); + await expectComposerModel(page, "Ten second stream"); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello"); +}); diff --git a/packages/app/src/composer/draft/workspace-tab-core.ts b/packages/app/src/composer/draft/workspace-tab-core.ts index 30411aeb00..26bf548064 100644 --- a/packages/app/src/composer/draft/workspace-tab-core.ts +++ b/packages/app/src/composer/draft/workspace-tab-core.ts @@ -24,7 +24,7 @@ export function validateDraftSubmission(input: { }; autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null; workspaceDirectory: string | null; - hasClient: boolean; + isConnected: boolean; }): string | null { const { text, @@ -32,7 +32,7 @@ export function validateDraftSubmission(input: { composerState, autoSubmitConfig, workspaceDirectory, - hasClient, + isConnected, } = input; const readiness = resolveSubmissionReadiness({ text, @@ -46,7 +46,7 @@ export function validateDraftSubmission(input: { }, autoSubmitConfig, workspaceDirectory, - hasClient, + isConnected, }); return readiness.ok ? null : (readiness.reason ?? null); } diff --git a/packages/app/src/composer/draft/workspace-tab.test.ts b/packages/app/src/composer/draft/workspace-tab.test.ts index 5952083a62..1d7e2d4b2c 100644 --- a/packages/app/src/composer/draft/workspace-tab.test.ts +++ b/packages/app/src/composer/draft/workspace-tab.test.ts @@ -17,12 +17,26 @@ function validate(overrides = {}) { composerState: baseComposerState, autoSubmitConfig: null, workspaceDirectory: "/tmp/project", - hasClient: true, + isConnected: true, ...overrides, }); } describe("workspace draft agent model validation", () => { + test("reports an offline host before an absent catalog or pending model defaults", () => { + expect( + validate({ + isConnected: false, + composerState: { + ...baseComposerState, + providerDefinitions: [], + selectedProvider: null, + isModelLoading: true, + }, + }), + ).toBe("Host is not connected"); + }); + test("allows a ready provider with no models to submit without a selected model", () => { expect(validate({})).toBeNull(); }); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index b54b344a36..27ca8b8af8 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -16,7 +16,7 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow"; import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -305,6 +305,7 @@ export function WorkspaceDraftAgentTab({ }: WorkspaceDraftAgentTabProps) { const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ workspaceDirectory: w.workspaceDirectory, id: w.id, @@ -445,7 +446,7 @@ export function WorkspaceDraftAgentTab({ composerState, autoSubmitConfig, workspaceDirectory: draftWorkingDirectory, - hasClient: Boolean(client), + isConnected, }); }, onBeforeSubmit: async () => { diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 26908bd6a0..1522ce082e 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -36,6 +36,7 @@ import { type FormState, type ProviderModelsByProvider, } from "@/provider-selection/resolve-agent-form"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { MaterializedAgentProfile } from "@/agent-profiles"; export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; @@ -186,6 +187,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo refetchIfStale: refetchSnapshotIfStale, } = useProvidersSnapshot(serverId, { cwd: workingDir }); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const snapshotProviderDefinitions = useMemo( () => buildProviderDefinitions(snapshotEntries), @@ -220,8 +223,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo [snapshotEntries], ); const snapshotModelSelectorProviders = useMemo( - () => buildSelectableProviderSelectorProviders(snapshotEntries), - [snapshotEntries], + () => buildSelectableProviderSelectorProviders(snapshotEntries, isConnected), + [snapshotEntries, isConnected], ); const snapshotSelectedEntry = useMemo( () => @@ -247,7 +250,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo const availableModels = snapshotSelectedProviderModels; const modeOptions = snapshotSelectedProviderModes; const isModelSelectionLoading = - resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading; + isConnected && + (resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading); const isAllModelsLoading = isModelSelectionLoading; useEffect(() => { diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 6c13ad3d83..1c04c0b154 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -15,6 +15,21 @@ import { } from "./provider-selection"; describe("combined model selector data", () => { + it("shows a disconnected error for unfinished catalogs while keeping known models", () => { + const providers = buildSelectableProviderSelectorProviders( + [ + snapshotEntry({ provider: "waiting", status: "loading", models: [] }), + snapshotEntry({ provider: "codex", status: "ready", models: [codexModel] }), + ], + false, + ); + expect(providers[0].modelSelection).toEqual({ + kind: "error", + message: "Host is not connected", + }); + expect(providers[1].modelSelection.kind).toBe("models"); + }); + const codexModel: AgentModelDefinition = { provider: "codex", id: "gpt-5.4", @@ -367,7 +382,7 @@ describe("combined model selector data", () => { }, autoSubmitConfig: null, workspaceDirectory: "/repo", - hasClient: true, + isConnected: true, }), ).toEqual({ ok: false, @@ -387,7 +402,7 @@ describe("combined model selector data", () => { }, autoSubmitConfig: null, workspaceDirectory: "/repo", - hasClient: true, + isConnected: true, }), ).toEqual({ ok: true }); }); @@ -426,7 +441,7 @@ describe("combined model selector data", () => { }, autoSubmitConfig: null, workspaceDirectory: "/repo", - hasClient: true, + isConnected: true, }), ).toEqual({ ok: false, reason: "初始 prompt 必填" }); } finally { diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index f66a1b1476..e0313c6623 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -103,6 +103,7 @@ function buildModelSelection( function buildEntryModelSelection( entry: ProviderSnapshotEntry, label: string, + isConnected: boolean, ): ProviderModelSelection { if ((entry.models?.length ?? 0) > 0) { return buildModelSelection(entry.provider, label, entry.models ?? null); @@ -111,7 +112,9 @@ function buildEntryModelSelection( return buildModelSelection(entry.provider, label, entry.models ?? null); } if (entry.status === "loading") { - return { kind: "loading" }; + return isConnected + ? { kind: "loading" } + : { kind: "error", message: i18n.t("providerSelection.readiness.hostDisconnected") }; } return { kind: "error", @@ -142,6 +145,7 @@ export function buildProviderSelectorProviders(input: { export function buildSelectableProviderSelectorProviders( entries: ProviderSnapshotEntry[] | undefined, + isConnected = true, ): ProviderSelectorProvider[] { return (entries ?? []) .filter((entry) => entry.enabled) @@ -150,7 +154,7 @@ export function buildSelectableProviderSelectorProviders( return { id: entry.provider, label, - modelSelection: buildEntryModelSelection(entry, label), + modelSelection: buildEntryModelSelection(entry, label, isConnected), }; }); } @@ -345,11 +349,14 @@ export function resolveSubmissionReadiness(input: { }; autoSubmitConfig: { provider: string; model: string | null } | null; workspaceDirectory: string | null; - hasClient: boolean; + isConnected: boolean; }): ProviderSelectionReadiness { if (!input.allowsEmptyAutoSubmit && !input.text.trim()) { return { ok: false, reason: i18n.t("providerSelection.readiness.initialPromptRequired") }; } + if (!input.isConnected) { + return { ok: false, reason: i18n.t("providerSelection.readiness.hostDisconnected") }; + } if (input.providerCount === 0) { return { ok: false, reason: i18n.t("providerSelection.readiness.noProviders") }; } @@ -366,8 +373,5 @@ export function resolveSubmissionReadiness(input: { if (!input.workspaceDirectory) { return { ok: false, reason: i18n.t("providerSelection.readiness.workspaceDirectoryNotFound") }; } - if (!input.hasClient) { - return { ok: false, reason: i18n.t("providerSelection.readiness.hostDisconnected") }; - } return { ok: true }; }