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 }; }