From dd3539261a35c9ddc4a840edb3647ba51e375ed9 Mon Sep 17 00:00:00 2001 From: "jianyu.zhang" <3193941960@qq.com> Date: Wed, 11 Nov 2026 16:45:22 +0800 Subject: [PATCH 1/3] Fix project model selection, scheduling, and authorization recovery --- apps/app/src/app/lib/ipollowork-server.ts | 10 ++ apps/app/src/i18n/locales/en.ts | 2 + apps/app/src/i18n/locales/zh.ts | 2 + .../provider-auth/provider-auth-modal.tsx | 50 +++++---- .../connections/provider-auth/store.ts | 27 +++-- .../domains/session/chat/session-page.tsx | 9 +- .../domains/session/sidebar/app-sidebar.tsx | 2 +- .../domains/session/sync/session-sync.ts | 5 +- .../react-app/domains/work/work-center.tsx | 1 - .../domains/work/work-item-sheet.tsx | 3 +- .../react-app/infra/preferred-chat-model.ts | 3 +- .../src/react-app/kernel/local-provider.tsx | 19 ++-- .../app/src/react-app/shell/session-route.tsx | 101 ++++++++++-------- .../src/react-app/shell/settings-route.tsx | 7 +- apps/app/tests/bash-tool-errors.test.ts | 1 - apps/app/tests/preferred-chat-model.test.ts | 10 ++ .../app/tests/provider-engine-adapter.test.ts | 20 +++- .../tests/session-sync-permissions.test.ts | 39 +++++++ .../tests/settings-provider-branding.test.ts | 2 +- .../tests/shared-provider-preferences.test.ts | 30 +++--- apps/app/tests/sidebar-projects.test.ts | 14 ++- .../src/ipollowork-workspace-config-store.ts | 12 ++- apps/server/src/routes/work-items.ts | 12 +++ apps/server/src/server.ts | 25 ++--- apps/server/src/work-items.test.ts | 64 +++++++++++ apps/server/src/work-items.ts | 56 ++++++++-- evals/flows/conversation-workflows.flow.mjs | 4 +- evals/flows/opencode-free-inference.flow.mjs | 8 +- .../flows/opencode-model-persistence.flow.mjs | 62 +++++++++++ evals/flows/opencode-stop-resume.flow.mjs | 92 ++++++++++++++++ evals/flows/project-engine-lock.flow.mjs | 55 ++++++++++ evals/flows/project-task-automation.flow.mjs | 72 +++++++++++++ evals/flows/provider-oauth-recovery.flow.mjs | 72 +++++++++++++ .../scheduled-task-project-model.flow.mjs | 89 +++++++++++++++ evals/voiceovers/conversation-workflows.md | 2 +- packages/types/src/work-items.ts | 6 ++ 36 files changed, 836 insertions(+), 152 deletions(-) create mode 100644 evals/flows/opencode-model-persistence.flow.mjs create mode 100644 evals/flows/opencode-stop-resume.flow.mjs create mode 100644 evals/flows/project-engine-lock.flow.mjs create mode 100644 evals/flows/project-task-automation.flow.mjs create mode 100644 evals/flows/provider-oauth-recovery.flow.mjs create mode 100644 evals/flows/scheduled-task-project-model.flow.mjs diff --git a/apps/app/src/app/lib/ipollowork-server.ts b/apps/app/src/app/lib/ipollowork-server.ts index 415066eefe..1ae51d43ab 100644 --- a/apps/app/src/app/lib/ipollowork-server.ts +++ b/apps/app/src/app/lib/ipollowork-server.ts @@ -31,6 +31,7 @@ import type { import type { WorkBoardConfig, WorkBoardConfigValue, + WorkAutomationModel, WorkItem, WorkItemCreateInput, WorkItemListResponse, @@ -1641,6 +1642,15 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?: timeoutMs: timeouts.config, }, ), + setWorkAutomationModel: (workspaceId: string, value: WorkAutomationModel, signal?: AbortSignal) => + requestJson<{ ok: boolean }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/work-automation-model`, { + token, + hostToken, + method: "PUT", + body: value, + timeoutMs: timeouts.config, + signal, + }), getWorkBoard: (workspaceId: string) => requestJson(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/work-board`, { token, diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 50daccad1a..b6f1b207c6 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -2626,6 +2626,8 @@ export default { "providers.not_connected": "Not connected to a server", "providers.not_oauth_flow_prefix": "Selected auth method is not an OAuth flow for", "providers.oauth_failed": "Failed to complete OAuth", + "providers.oauth_unconfirmed": "The app could not confirm this authorization. Start a new authorization and sign in on the new browser page.", + "providers.oauth_retry": "Restart authorization", "providers.oauth_method_required": "OAuth method is required", "providers.provider_error": "Provider error ({provider})", "providers.provider_id_required": "Provider ID is required", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 68fae944d8..58985781be 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -2353,6 +2353,8 @@ export default { "providers.not_connected": "未连接到服务器", "providers.not_oauth_flow_prefix": "所选认证方式不是OAuth流程:", "providers.oauth_failed": "完成OAuth失败", + "providers.oauth_unconfirmed": "应用尚未确认这次授权。请重新授权,并在新打开的浏览器页面完成登录。", + "providers.oauth_retry": "重新授权", "providers.oauth_method_required": "OAuth方式为必填项", "providers.provider_error": "提供商错误({provider})", "providers.provider_id_required": "提供商ID为必填项", diff --git a/apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx b/apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx index df746e73c0..b31c986065 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx +++ b/apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx @@ -24,6 +24,7 @@ import { } from "@/components/ui/dialog"; import { desktopFetchViaMain, openDesktopAuthUrl, openDesktopUrl } from "@/app/lib/desktop"; import { isDesktopRuntime } from "@/app/utils"; +import { t } from "@/i18n"; import { Button } from "@/components/ui/button"; import { ProviderIcon } from "../../../design-system/provider-icon"; import { TextInput } from "../../../design-system/text-input"; @@ -43,6 +44,7 @@ import type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult, + ProviderRefreshOptions, } from "./store"; type ProviderOAuthSession = ProviderOAuthStartResult & { @@ -71,7 +73,7 @@ export type ProviderAuthModalProps = { methodIndex: number, code?: string, ) => Promise<{ connected: boolean; pending?: boolean; message?: string }>; - onRefreshProviders?: () => Promise; + onRefreshProviders?: (options?: ProviderRefreshOptions) => Promise; showiPolloWorkModelsSubscribe?: boolean; onSubscribeiPolloWorkModels?: () => void | Promise; onClose: () => void; @@ -102,7 +104,8 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { const searchInputRef = useRef(null); const providerPollRef = useRef(null); - const oauthAutoCompletionKeyRef = useRef(null); + const providerPollInFlightRef = useRef(false); + const oauthAutoCompletionRef = useRef(null); const oauthCodeCopiedResetRef = useRef(null); const autoOpenedPreferredProviderIdRef = useRef(null); @@ -205,7 +208,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { setSearchQuery(""); setActiveEntryIndex(0); setLocalError(null); - oauthAutoCompletionKeyRef.current = null; + oauthAutoCompletionRef.current = null; setOauthAutoBusy(false); setOauthCodeCopied(false); setOauthBrowserOpened(false); @@ -286,17 +289,15 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { !!activeProviderId && (props.connectedProviderIds ?? []).includes(activeProviderId); const pollProviders = async () => { - const id = activeProviderId; - if (!id || pollingBusy) return; + if (!activeProviderId || providerPollInFlightRef.current) return; + providerPollInFlightRef.current = true; setPollingBusy(true); try { - await props.onRefreshProviders?.(); + await props.onRefreshProviders?.({ skipSharedImport: true }); } finally { + providerPollInFlightRef.current = false; setPollingBusy(false); } - if ((props.connectedProviderIds ?? []).includes(id)) { - handleClose(); - } }; const startProviderPolling = () => { @@ -367,16 +368,20 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { }; const attemptOauthAutoCompletion = async (session: ProviderOAuthSession) => { - const completionKey = `${session.providerId}:${session.methodIndex}`; - if (oauthAutoCompletionKeyRef.current === completionKey) return; - oauthAutoCompletionKeyRef.current = completionKey; + if (oauthAutoCompletionRef.current === session) return; + oauthAutoCompletionRef.current = session; setOauthAutoBusy(true); try { - await submitOauth(session.providerId, session.methodIndex); - } catch { - // submitOauth already exposes the actionable provider error in the modal. + const result = await props.onSubmitOAuth(session.providerId, session.methodIndex); + if (oauthAutoCompletionRef.current !== session) return; + if (result.connected) handleClose(); + else setLocalError(t("providers.oauth_unconfirmed")); + } catch (error) { + if (oauthAutoCompletionRef.current === session) { + setLocalError(error instanceof Error ? error.message : t("providers.oauth_failed")); + } } finally { - if (oauthAutoCompletionKeyRef.current === completionKey) { + if (oauthAutoCompletionRef.current === session) { setOauthAutoBusy(false); } } @@ -396,6 +401,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { setLocalError(null); setOauthCodeInput(""); setOauthSession(null); + oauthAutoCompletionRef.current = null; setOauthCodeCopied(false); setOauthBrowserOpened(false); try { @@ -595,7 +601,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { } setOauthSession(null); setOauthCodeInput(""); - oauthAutoCompletionKeyRef.current = null; + oauthAutoCompletionRef.current = null; setOauthAutoBusy(false); setOauthCodeCopied(false); setOauthBrowserOpened(false); @@ -1141,7 +1147,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
{selectedEntry.name}
-
Waiting for browser confirmation.
+ {!errorMessage ?
Waiting for browser confirmation.
: null}
) : null} - {isOpenAiHeadlessSession && !oauthBrowserOpened ? ( + {errorMessage ? null : isOpenAiHeadlessSession && !oauthBrowserOpened ? (
Authorization checks will start after you click Open Browser.
@@ -1183,11 +1189,13 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
) : null}
diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 4349a5ae42..8024de2911 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx @@ -141,7 +141,7 @@ interface SessionStatusIndicatorProps { isActive: boolean; } -function workspaceEngineLabel(engineId?: string | null) { +export function workspaceEngineLabel(engineId?: string | null) { const resolvedEngineId = engineId?.trim() || DEFAULT_ENGINE_ID; if (resolvedEngineId === CODEX_HARNESS_ENGINE_ID) return t("projects.engine_codex"); if (resolvedEngineId === DEEPSEEK_HARNESS_ENGINE_ID) return t("projects.engine_dsh"); diff --git a/apps/app/src/react-app/domains/session/sync/session-sync.ts b/apps/app/src/react-app/domains/session/sync/session-sync.ts index 53e1355879..8f3801524b 100644 --- a/apps/app/src/react-app/domains/session/sync/session-sync.ts +++ b/apps/app/src/react-app/domains/session/sync/session-sync.ts @@ -193,8 +193,11 @@ function observeInterruptedUserMessage( run.resumeStartedAt = null; return "resumed"; } + // OpenCode can announce a native user ID before sending the prompt parts. + // Leave unconfirmed IDs eligible to resume when the text arrives. + if (run.interrupted) return "ignore"; run.observedUserMessageIds.add(message.id); - return run.interrupted ? "ignore" : "normal"; + return "normal"; } function reconcileInterruptedUserMessage(workspaceId: string, sessionId: string, run: InterruptedRun, message: UIMessage) { diff --git a/apps/app/src/react-app/domains/work/work-center.tsx b/apps/app/src/react-app/domains/work/work-center.tsx index 2d87e2bdfc..f0a0e6df24 100644 --- a/apps/app/src/react-app/domains/work/work-center.tsx +++ b/apps/app/src/react-app/domains/work/work-center.tsx @@ -638,7 +638,6 @@ export function WorkCenter(props: WorkCenterProps) { board={editorBoard} defaultStatus={createStatus || editorBoard.columns[0]?.id || "planned"} scheduleMode={props.mode === "global" || projectView === "schedule"} - allowAutomation={!props.sessionId || Boolean(editingEntry?.item.automation)} initialSchedule={createSchedule} saving={createMutation.isPending || updateMutation.isPending} deleting={deleteMutation.isPending} diff --git a/apps/app/src/react-app/domains/work/work-item-sheet.tsx b/apps/app/src/react-app/domains/work/work-item-sheet.tsx index 4fbc2d5a84..6d18b8bc6e 100644 --- a/apps/app/src/react-app/domains/work/work-item-sheet.tsx +++ b/apps/app/src/react-app/domains/work/work-item-sheet.tsx @@ -68,7 +68,6 @@ type WorkItemSheetProps = { board: WorkBoardConfig; defaultStatus: string; scheduleMode: boolean; - allowAutomation?: boolean; initialSchedule: WorkItemScheduleDraft | null; saving: boolean; deleting: boolean; @@ -615,7 +614,7 @@ export function WorkItemSheet(props: WorkItemSheetProps) { : null}
{invalidRange ?

{t("work.field.invalid_range")}

: null} - {!props.item?.execution && props.allowAutomation !== false ? ( + {!props.item?.execution ? (
diff --git a/apps/app/src/react-app/infra/preferred-chat-model.ts b/apps/app/src/react-app/infra/preferred-chat-model.ts index 023c33884a..4b86d1dc34 100644 --- a/apps/app/src/react-app/infra/preferred-chat-model.ts +++ b/apps/app/src/react-app/infra/preferred-chat-model.ts @@ -56,7 +56,7 @@ export function resolvePreferredSelectableChatModel(input: { * Resolve the model an engine can execute without changing the app-wide * preference. OpenCode defaults to its live free models; a paid shared choice * inherited from another engine is not consent to use that route in OpenCode. - * An available choice made in this engine remains authoritative. + * A choice made in this engine stays selected even while it is unavailable. */ export function resolveEngineSelectableChatModel(input: { engineId?: string | null; @@ -76,6 +76,7 @@ export function resolveEngineSelectableChatModel(input: { if (current && input.providers.some((provider) => ( provider.providerID === current.providerID && provider.modelIDs.includes(current.modelID) ))) return current; + if (input.selectedForEngine) return null; if (isOpenCode) { const defaultModel = input.defaults?.opencode; diff --git a/apps/app/src/react-app/kernel/local-provider.tsx b/apps/app/src/react-app/kernel/local-provider.tsx index 1fea4235a3..3e58ad1412 100644 --- a/apps/app/src/react-app/kernel/local-provider.tsx +++ b/apps/app/src/react-app/kernel/local-provider.tsx @@ -58,6 +58,8 @@ export type LocalPreferences = { export type EnginePreferences = { mode: string | null; + model?: ModelRef; + modelVariant?: string | null; }; const UI_STORAGE_KEY = "ipollowork.ui"; @@ -99,9 +101,7 @@ export function updateEnginePreferences( ...preferences, enginePreferences: { ...preferences.enginePreferences, - [resolvedEngineId]: { - mode: next.mode, - }, + [resolvedEngineId]: { ...previousEngine, ...next }, }, }; } @@ -109,11 +109,14 @@ export function updateEnginePreferences( export function updateModelPreferences( preferences: LocalPreferences, updater: (previous: Pick) => Pick, + engineId?: string, ): LocalPreferences { - return { - ...preferences, - ...updater({ model: preferences.model, modelVariant: preferences.modelVariant }), - }; + const engine = engineId ? getEnginePreferences(preferences, engineId) : undefined; + const next = updater(engine?.model + ? { model: engine.model, modelVariant: engine.modelVariant ?? null } + : { model: preferences.model, modelVariant: preferences.modelVariant }); + const updated = { ...preferences, ...next }; + return engineId ? updateEnginePreferences(updated, engineId, (current) => ({ ...current, ...next })) : updated; } function normalizeModelRef(value: unknown): ModelRef | null { @@ -130,8 +133,10 @@ function normalizeEnginePreferences(value: unknown): Record { if (!selection || typeof selection !== "object" || Array.isArray(selection)) return []; const record = selection as Partial; + const model = normalizeModelRef(record.model); return [[engineId, { mode: typeof record.mode === "string" ? record.mode : null, + ...(model ? { model, modelVariant: typeof record.modelVariant === "string" ? record.modelVariant : null } : {}), }]]; }), ); diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 3928239674..73b203e737 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -14,6 +14,7 @@ import type { PptxCompatibility, TemplateCategory, TemplateSessionSnapshot } fro import { DEEPSEEK_HARNESS_ENGINE_ID, DEFAULT_ENGINE_ID, + isBuiltInWorkspaceEngineId, type BuiltInWorkspaceEngineId, } from "@ipollowork/types/workspace"; import { isOpenCodeZenPublicModel } from "@ipollowork/types/opencode-zen-public-models"; @@ -204,7 +205,6 @@ import { ensureProviderListQuery, getModelContextWindow, getRunnableChatModelSnapshot, - getSelectableChatModelSnapshot, providerListExposesModel, projectAccountProviderConnections, type ProviderListQueryInput, @@ -378,9 +378,10 @@ export function SessionRoute() { }); const [newConversationEngineId, setNewConversationEngineId] = useState(null); const selectedConversation = sessionsByWorkspaceId[selectedWorkspaceId]?.find((item) => item.id === selectedSessionId); - const activeEngineId = selectedSessionId - ? selectedConversation?.engineId || selectedWorkspace?.engineId?.trim() || DEFAULT_ENGINE_ID - : newConversationEngineId || selectedWorkspace?.engineId?.trim() || DEFAULT_ENGINE_ID; + const workspaceEngineId = selectedWorkspace?.engineId?.trim(); + const projectEngineId = selectedWorkspace?.isDefault ? undefined : workspaceEngineId; + const activeEngineId = (selectedSessionId ? selectedConversation?.engineId : projectEngineId || newConversationEngineId) + || workspaceEngineId || DEFAULT_ENGINE_ID; useEffect(() => setNewConversationEngineId(null), [selectedWorkspaceId]); const conversation = useMemo( () => opencodeBaseUrl && selectedWorkspaceServerToken && !selectedWorkspaceError && (!selectedSessionId || selectedConversation) @@ -429,8 +430,7 @@ export function SessionRoute() { }, [conversation, selectedWorkspaceEndpoint?.client, selectedWorkspaceEndpoint?.workspaceId]); const activeEnginePreferences = getEnginePreferences(local.prefs, activeEngineId); const selectedModel = local.prefs.model; - const [engineModelSelections, setEngineModelSelections] = useState>({}); - const engineModelSelection = engineModelSelections[activeEngineId]; + const engineModelSelection = activeEnginePreferences.model; const selectedMode = activeEnginePreferences.mode; const [modeSelectionLocked, setModeSelectionLocked] = useState(false); useEffect(() => { @@ -925,7 +925,6 @@ export function SessionRoute() { disabledProviderIds: hiddenProviderIds, }); const setSelectedModel = useCallback((model: ModelRef) => { - setEngineModelSelections((previous) => ({ ...previous, [activeEngineId]: model })); local.setPrefs((previous) => updateModelPreferences( previous, (selection) => ({ @@ -935,14 +934,16 @@ export function SessionRoute() { ? selection.modelVariant : null, }), + activeEngineId, )); }, [activeEngineId, local.setPrefs]); const setModelVariant = useCallback((modelVariant: string | null) => { local.setPrefs((previous) => updateModelPreferences( previous, (selection) => ({ ...selection, modelVariant }), + engineModelSelection ? activeEngineId : undefined, )); - }, [local.setPrefs]); + }, [activeEngineId, engineModelSelection, local.setPrefs]); const selectableModels = getRunnableChatModelSnapshot({ catalog: accountProviderList, runtime: activeProviderList, @@ -960,42 +961,43 @@ export function SessionRoute() { ) ) )); - const accountSelectableModels = getSelectableChatModelSnapshot(accountProviderList, activeEngineId); - const explicitlySelectedModel = engineModelSelection - && accountSelectableModels.some((provider) => ( - provider.providerID === engineModelSelection.providerID - && provider.modelIDs.includes(engineModelSelection.modelID) - )) - // Preserve the explicit click while discovery is pending. Once the active - // engine has answered, do not keep a model that only exists in another - // engine's account catalog (or in an obsolete packaged whitelist). - && (!activeProviderList || permittedSelectableModels.some((provider) => ( - provider.providerID === engineModelSelection.providerID - && provider.modelIDs.includes(engineModelSelection.modelID) - ))) - ? engineModelSelection - : null; - // A user click is authoritative for the current engine. Runtime discovery - // can still be catching up during first launch; silently substituting its - // default here makes the picker and the composer show different models and - // can send the task with a model the user did not choose. - const activeSelectedModel = explicitlySelectedModel ?? (activeProviderList + const activeSelectedModel = activeProviderList ? resolveEngineSelectableChatModel({ engineId: activeEngineId, providers: permittedSelectableModels, defaults: activeProviderList.default, preferred: selectedModel, + selectedForEngine: engineModelSelection, }) - : null); + : null; + const displayedModel = engineModelSelection ?? activeSelectedModel; const usesSharedModelPreference = Boolean( selectedModel && activeSelectedModel && selectedModel.providerID === activeSelectedModel.providerID && selectedModel.modelID === activeSelectedModel.modelID, ); - const activeModelVariant = usesSharedModelPreference - ? local.prefs.modelVariant - : null; + const activeModelVariant = engineModelSelection + ? activeEnginePreferences.modelVariant ?? null + : usesSharedModelPreference ? local.prefs.modelVariant : null; + const automationModelProviderId = activeSelectedModel?.providerID; + const automationModelId = activeSelectedModel?.modelID; + const isNamedProject = Boolean(selectedWorkspace && !selectedWorkspace.isDefault); + useEffect(() => { + if (!selectedWorkspaceEndpoint || !isNamedProject + || activeEngineId !== (workspaceEngineId || DEFAULT_ENGINE_ID) + || !automationModelProviderId || !automationModelId) return; + const controller = new AbortController(); + void selectedWorkspaceEndpoint.client.setWorkAutomationModel(selectedWorkspaceEndpoint.workspaceId, { + engineId: activeEngineId, + model: { providerId: automationModelProviderId, modelId: automationModelId }, + modelVariant: activeModelVariant, + }, controller.signal).catch((error) => { + if (!controller.signal.aborted) console.warn("[work-automation] Could not sync the project model", error); + }); + return () => controller.abort(); + }, [activeEngineId, activeModelVariant, automationModelId, automationModelProviderId, isNamedProject, + selectedWorkspaceEndpoint, workspaceEngineId]); const { providerCatalog, modelVariantLabel, modelBehaviorOptions, modelVariantValue } = useModelBehavior({ providerList: accountProviderList, @@ -1120,8 +1122,8 @@ export function SessionRoute() { }; }, [denSessionVersion, sharedProviderClient, sharedProviderEndpoint?.opencodeBaseUrl, sharedProviderEngineId, sharedProviderRoot]); - const modelLabel = activeSelectedModel - ? resolveModelDisplayName(activeSelectedModel.modelID) + const modelLabel = displayedModel + ? resolveModelDisplayName(displayedModel.modelID) : t("session.default_model"); const listSlashCommands = useCallback(async (): Promise => { @@ -1256,7 +1258,7 @@ export function SessionRoute() { modelPickerOpen: modelPicker.compactOpen, modelStatus, onRetryModelLoad: retryModelLoad, - selectedModel: activeSelectedModel ?? { providerID: "", modelID: "" }, + selectedModel: displayedModel ?? { providerID: "", modelID: "" }, modelContextWindow: selectedModelContextWindow, onModelPickerOpenChange: (open: boolean) => { if (open && !hasUsableModel) { @@ -1328,7 +1330,7 @@ export function SessionRoute() { const text = (draft.resolvedText ?? draft.text).trim(); if (!text && draft.attachments.length === 0) return false; - let effectiveModel = activeSelectedModel; + let effectiveModel = displayedModel; let effectiveMode = selectedMode; let effectiveModelVariant = modelVariantValue; let effectiveRuntimeProviderList = activeProviderList; @@ -1336,6 +1338,7 @@ export function SessionRoute() { activeProviderSource && ( !effectiveRuntimeProviderList + || (engineModelSelection && !activeSelectedModel) || (effectiveModel && !providerListExposesModel(effectiveRuntimeProviderList, effectiveModel)) ) ) { @@ -1368,6 +1371,12 @@ export function SessionRoute() { ) ) )); + if (engineModelSelection && !effectiveSelectableModels.some((provider) => ( + provider.providerID === engineModelSelection.providerID && provider.modelIDs.includes(engineModelSelection.modelID) + ))) { + toast.error(t("composer.model_unavailable")); + return false; + } const reliableVideoOrchestration = ["publish-douyin", "publish-wechat-channels"].includes(videoDeliveryIntentForPrompt(text) ?? ""); if ( reliableVideoOrchestration @@ -1398,7 +1407,7 @@ export function SessionRoute() { providers: effectiveSelectableModels, defaults: effectiveRuntimeProviderList.default, preferred: effectiveModel, - selectedForEngine: effectiveModel, + selectedForEngine: engineModelSelection, }); effectiveModelVariant = null; } @@ -2103,6 +2112,8 @@ export function SessionRoute() { permittedSelectableModels, selectedMode, activeSelectedModel, + displayedModel, + engineModelSelection, selectedSessionId, selectedModelSupportsAttachments, selectedModelContextWindow, @@ -2196,11 +2207,15 @@ export function SessionRoute() { try { setErrorsByWorkspaceId((current) => ({ ...current, [workspaceId]: null })); setRouteError(null); + const targetWorkspaceEngineId = workspace.engineId?.trim() || undefined; + const engineId = (workspace.isDefault ? undefined : targetWorkspaceEngineId) + ?? conversationOptions?.engineId + ?? (workspaceId === selectedWorkspaceId ? activeEngineId : targetWorkspaceEngineId); const { item: session } = await endpoint.client.createSession( endpoint.workspaceId, undefined, - activeSelectedModel, - conversationOptions?.engineId || (workspaceId === selectedWorkspaceId ? activeEngineId : workspace.engineId || undefined), + engineId === activeEngineId ? activeSelectedModel : undefined, + engineId, ); createdSessionId = session.id; const workTemplateId = conversationOptions?.workTemplateId @@ -2210,7 +2225,7 @@ export function SessionRoute() { await endpoint.client.setConversationWorkflow(endpoint.workspaceId, session.id, { templateId: workTemplateId, source: "manual", - runtime: { engineId: session.engineId || activeEngineId, model: null, mode: null, modelVariant: null }, + runtime: { engineId: session.engineId || engineId || DEFAULT_ENGINE_ID, model: null, mode: null, modelVariant: null }, }); projectInitializationFailed = false; } @@ -2377,7 +2392,7 @@ export function SessionRoute() { targetWorkspaceId = await createProject({ name: t("session.untitled"), folderPath: "", - engineId: DEFAULT_ENGINE_ID, + engineId: isBuiltInWorkspaceEngineId(activeEngineId) ? activeEngineId : DEFAULT_ENGINE_ID, }) || ""; } if (!targetWorkspaceId) return false; @@ -3063,7 +3078,7 @@ export function SessionRoute() { onSelectProject: selectProject, onCreateProject: createProject, onCreateInitialProjectTask: handleCreateInitialProjectTask, - onSelectConversationEngine: setNewConversationEngineId, + onSelectConversationEngine: projectEngineId ? undefined : setNewConversationEngineId, onCreateTaskFromDraft: handleCreateTaskFromDraft, onRenameProject: renameProject, onRevealProject: revealProject, @@ -3226,7 +3241,7 @@ export function SessionRoute() { query={modelPicker.query} setQuery={modelPicker.setQuery} target="default" - current={activeSelectedModel ?? ({ providerID: "", modelID: "" } satisfies ModelRef)} + current={displayedModel ?? ({ providerID: "", modelID: "" } satisfies ModelRef)} onSelect={(next: ModelRef) => { setSelectedModel(next); modelPicker.setOpen(false); diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index d706459fc5..7f81e8e44d 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -103,7 +103,7 @@ import { useBootState } from "./boot-state"; import { SettingsShell } from "@/react-app/domains/settings/shell/settings-shell"; import { createExtensionsStore, useExtensionsStoreSnapshot } from "@/react-app/domains/settings/state/extensions-store"; import { usePlatform } from "@/react-app/kernel/platform"; -import { updateModelPreferences, useLocal } from "@/react-app/kernel/local-provider"; +import { getEnginePreferences, updateModelPreferences, useLocal } from "@/react-app/kernel/local-provider"; import { ipolloworkServerRestart, engineStart, @@ -991,8 +991,9 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { ? selection.modelVariant : null, }), + activeEngineId, )); - }, [local.setPrefs]); + }, [activeEngineId, local.setPrefs]); useEffect(() => { setActiveClient(opencodeClient); @@ -2367,7 +2368,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { setQuery={modelPicker.setQuery} target="default" current={ - local.prefs.model ?? { providerID: "", modelID: "" } + getEnginePreferences(local.prefs, activeEngineId).model ?? local.prefs.model } onSelect={(next: ModelRef) => { setActiveModel(next); diff --git a/apps/app/tests/bash-tool-errors.test.ts b/apps/app/tests/bash-tool-errors.test.ts index 0bf3e93026..48b034046f 100644 --- a/apps/app/tests/bash-tool-errors.test.ts +++ b/apps/app/tests/bash-tool-errors.test.ts @@ -11,6 +11,5 @@ describe("Bash tool errors", () => { expect(source).toContain('part.state === "output-error"'); expect(source).toContain("part.errorText"); expect(source).toContain("Command failed or was interrupted"); - expect(source).toContain("defaultOpen={isError}"); }); }); diff --git a/apps/app/tests/preferred-chat-model.test.ts b/apps/app/tests/preferred-chat-model.test.ts index bb008e94be..1c568ae72d 100644 --- a/apps/app/tests/preferred-chat-model.test.ts +++ b/apps/app/tests/preferred-chat-model.test.ts @@ -142,4 +142,14 @@ describe("resolveEngineSelectableChatModel", () => { preferred: { providerID: "openai", modelID: "gpt-5.5" }, })).toBeNull(); }); + + test("keeps the explicit engine choice through a temporary provider outage", () => { + const selectedForEngine = { providerID: "openai", modelID: "gpt-5.6-sol" }; + const free = { providerID: "opencode", modelIDs: ["big-pickle"], freeModelIDs: ["big-pickle"] }; + const input = { selectedForEngine, preferred: null, defaults: { opencode: "big-pickle" } }; + expect(resolveEngineSelectableChatModel({ ...input, providers: [free] })).toBeNull(); + expect(resolveEngineSelectableChatModel({ + ...input, providers: [free, { providerID: "openai", modelIDs: ["gpt-5.6-sol"] }], + })).toEqual(selectedForEngine); + }); }); diff --git a/apps/app/tests/provider-engine-adapter.test.ts b/apps/app/tests/provider-engine-adapter.test.ts index 5f8273beb5..8e7779e540 100644 --- a/apps/app/tests/provider-engine-adapter.test.ts +++ b/apps/app/tests/provider-engine-adapter.test.ts @@ -341,12 +341,13 @@ describe("model runtime adapters", () => { queryClient.clear(); }); - test("keeps provider OAuth start and completion requests single-flight", async () => { + test("keeps provider OAuth single-flight and defers credential imports while waiting for the browser", async () => { const queryClient = getReactQueryClient(); queryClient.clear(); const { calls, client } = createOpenCodeProviderClient(); let releaseAuthorize = () => {}; let releaseCallback = () => {}; + let sharedCredentialReads = 0; const authorizeGate = new Promise((resolve) => { releaseAuthorize = resolve; }); @@ -416,8 +417,17 @@ describe("model runtime adapters", () => { runtimeWorkspaceId: () => "workspace-oauth-single-flight", ipolloworkServer: { getSnapshot: () => ({ - ipolloworkServerStatus: "disconnected", - ipolloworkServerClient: null, + ipolloworkServerStatus: "connected", + ipolloworkServerClient: { + listUserEnvKeys: async () => ({ keys: [sharedProviderCredentialEnvKey("minimax")] }), + getUserEnv: async () => { + sharedCredentialReads++; + throw new Error("Fixture credential is offline"); + }, + reloadEngine: async () => ({ ok: true }), + upsertUserEnv: async () => ({ updated: [] }), + deleteUserEnv: async () => ({ ok: true }), + } as never, ipolloworkServerCapabilities: null, }), }, @@ -438,10 +448,14 @@ describe("model runtime adapters", () => { const firstCompletion = store.completeProviderAuthOAuth("openai", 0); const duplicateCompletion = store.completeProviderAuthOAuth("openai", 0); + await store.refreshProviders(); + expect(sharedCredentialReads).toBe(0); releaseCallback(); expect(await firstCompletion).toMatchObject({ connected: true }); expect(await duplicateCompletion).toMatchObject({ connected: true }); expect(calls.filter((call) => call.name === "callback")).toHaveLength(1); + await store.refreshProviders(); + expect(sharedCredentialReads).toBe(1); store.dispose(); queryClient.clear(); diff --git a/apps/app/tests/session-sync-permissions.test.ts b/apps/app/tests/session-sync-permissions.test.ts index 97cdd689ad..ba0bb741da 100644 --- a/apps/app/tests/session-sync-permissions.test.ts +++ b/apps/app/tests/session-sync-permissions.test.ts @@ -992,6 +992,45 @@ describe("session transcript sync", () => { } }); + test.each(["live parts", "snapshot"])("resumes after an OpenCode user message header when text arrives via %s", (source) => { + const cleanup = __createWorkspaceSessionSyncForTest(syncInput); + const release = trackWorkspaceSessionSync(syncInput, "session-a"); + const previous = { id: "native-stopped", role: "user" as const, text: "换个内容" }; + const resumed = { id: "native-resumed", role: "user" as const, text: "继续" }; + const answer = { id: "resumed-answer", role: "assistant" as const, text: "新的回复", parentUserMessageId: resumed.id }; + seedSessionState("workspace-a", snapshotWithMessages([previous])); + settleInterruptedSessionRun("workspace-a", "session-a", previous.id); + beginOptimisticSessionPrompt("workspace-a", "session-a", resumed.text, "local-resumed"); + + try { + __applySessionSyncEventForTest(syncInput, { + type: "message.upsert", sessionId: "session-a", message: { id: resumed.id, role: "user", parts: [] }, + }); + if (source === "live parts") { + __applySessionSyncEventForTest(syncInput, { + type: "message.parts", sessionId: "session-a", messageId: resumed.id, partId: "resumed-text", + messageRole: "user", visibleAssistantOutput: false, + parts: [{ type: "text", text: resumed.text, state: "done" }], + }); + __applySessionSyncEventForTest(syncInput, { + type: "message.upsert", sessionId: "session-a", + message: uiMessage(answer.id, answer.role, answer.text, answer), + }); + } else { + seedSessionState("workspace-a", snapshotWithMessages([previous, resumed, answer])); + } + __applySessionSyncEventForTest(syncInput, { type: "session.idle", sessionId: "session-a" }); + + const query = getReactQueryClient(); + expect(query.getQueryData(transcriptKey("workspace-a", "session-a"))?.map(message => message.id)) + .toEqual([previous.id, resumed.id, answer.id]); + expect(query.getQueryData(statusKey("workspace-a", "session-a"))).toEqual({ type: "idle" }); + } finally { + release(); + cleanup(); + } + }); + test("never resurrects a stopped turn after the next identical turn completes", () => { const cleanup = __createWorkspaceSessionSyncForTest(syncInput); const releaseSession = trackWorkspaceSessionSync(syncInput, "session-a"); diff --git a/apps/app/tests/settings-provider-branding.test.ts b/apps/app/tests/settings-provider-branding.test.ts index a3a027acee..199f67c407 100644 --- a/apps/app/tests/settings-provider-branding.test.ts +++ b/apps/app/tests/settings-provider-branding.test.ts @@ -71,7 +71,7 @@ describe("settings provider branding", () => { }); test("waits for the OpenAI OAuth callback only once per authorization session", () => { - expect(providerAuthModalSource).toContain("oauthAutoCompletionKeyRef"); + expect(providerAuthModalSource).toContain("oauthAutoCompletionRef.current === session"); expect(providerAuthModalSource).not.toContain("oauthAutoPollRef"); expect(providerAuthModalSource).not.toContain("startOauthAutoPolling"); }); diff --git a/apps/app/tests/shared-provider-preferences.test.ts b/apps/app/tests/shared-provider-preferences.test.ts index 4abbce9e20..0bb2861f27 100644 --- a/apps/app/tests/shared-provider-preferences.test.ts +++ b/apps/app/tests/shared-provider-preferences.test.ts @@ -84,19 +84,17 @@ describe("shared AI provider preferences", () => { mode: "code", }); - const updated = updateModelPreferences(initial, (current) => ({ - model: { providerID: "anthropic", modelID: "claude-sonnet" }, - modelVariant: null, - })); - - expect(updated.model).toEqual({ - providerID: "anthropic", - modelID: "claude-sonnet", - }); + const openCode = { model: { providerID: "openai", modelID: "gpt-5" }, modelVariant: "high" }; + const deepSeek = { model: { providerID: "deepseek-official", modelID: "deepseek-v4-flash" }, modelVariant: null }; + const first = updateModelPreferences(initial, () => openCode, DEFAULT_ENGINE_ID); + const updated = updateModelPreferences(first, () => deepSeek, DEEPSEEK_HARNESS_ENGINE_ID); const modeUpdated = updateEnginePreferences(updated, DEEPSEEK_HARNESS_ENGINE_ID, () => ({ mode: "review" })); - expect(getEnginePreferences(modeUpdated, DEEPSEEK_HARNESS_ENGINE_ID).mode).toBe("review"); - expect(getEnginePreferences(modeUpdated, DEFAULT_ENGINE_ID).mode).toBe("build"); - expect(modeUpdated.model).toEqual(updated.model); + expect(getEnginePreferences(modeUpdated, DEFAULT_ENGINE_ID)).toEqual({ mode: "build", ...openCode }); + expect(getEnginePreferences(modeUpdated, DEEPSEEK_HARNESS_ENGINE_ID)).toEqual({ mode: "review", ...deepSeek }); + expect(modeUpdated.model).toEqual(deepSeek.model); + const variantUpdated = updateModelPreferences(modeUpdated, (selection) => ({ ...selection, modelVariant: "low" }), DEFAULT_ENGINE_ID); + expect(getEnginePreferences(variantUpdated, DEFAULT_ENGINE_ID)).toEqual({ mode: "build", ...openCode, modelVariant: "low" }); + expect(getEnginePreferences(variantUpdated, DEEPSEEK_HARNESS_ENGINE_ID)).toEqual({ mode: "review", ...deepSeek }); }); test("uses the OpenCode workspace as the shared provider control plane", () => { @@ -134,15 +132,13 @@ describe("shared AI provider preferences", () => { expect(settingsRouteSource).toContain("runtimeSource: activeModelProviderSource"); expect(settingsRouteSource).toContain("connectedProviderIds: providerAuthSnapshot.connectedProviderIds"); expect(sessionRouteSource).toContain("getRunnableChatModelSnapshot({"); - expect(sessionRouteSource).toContain("getSelectableChatModelSnapshot(accountProviderList, activeEngineId)"); expect(sessionRouteSource).toContain("runtime: activeProviderList"); expect(sessionRouteSource).toContain("runtimeSource: activeProviderSource"); expect(sessionRouteSource).toContain("model: effectiveModel"); expect(sessionRouteSource).toContain("providerId: effectiveModel.providerID"); - expect(sessionRouteSource).toContain("setEngineModelSelections((previous) => ({ ...previous, [activeEngineId]: model }))"); - expect(sessionRouteSource).toContain("const engineModelSelection = engineModelSelections[activeEngineId]"); - expect(sessionRouteSource).toContain("selectedForEngine: effectiveModel"); - expect(sessionRouteSource).toContain("const activeSelectedModel = explicitlySelectedModel ?? (activeProviderList"); + expect(sessionRouteSource).toContain("const engineModelSelection = activeEnginePreferences.model"); + expect(sessionRouteSource).toContain("selectedForEngine: engineModelSelection"); + expect(sessionRouteSource).toContain("const displayedModel = engineModelSelection ?? activeSelectedModel"); }); test("renders provider choices before loading engine auth metadata", () => { diff --git a/apps/app/tests/sidebar-projects.test.ts b/apps/app/tests/sidebar-projects.test.ts index 8598a03341..d8c72b4ea8 100644 --- a/apps/app/tests/sidebar-projects.test.ts +++ b/apps/app/tests/sidebar-projects.test.ts @@ -236,7 +236,7 @@ describe("sidebar projects", () => { expect(sidebarSource).not.toContain("createUngroupedConversation"); }); - test("creates the first project and task with OpenCode only after the starter is submitted", () => { + test("creates the first project and task with the selected engine when the starter is submitted", () => { expect(sessionPageSource).toContain('data-testid="initial-project-task-starter"'); expect(sessionPageSource).toContain(''); expect(sessionPageSource).not.toContain('rounded-2xl bg-dls-text px-4 py-3 text-sm leading-6 text-dls-surface'); @@ -268,7 +268,7 @@ describe("sidebar projects", () => { expect(sessionSurfaceSource).toContain("modelContextWindow={props.modelContextWindow}"); expect(composerSource).toContain('data-testid="composer-context-health"'); expect(sessionRouteSource).toContain("const handleCreateInitialProjectTask = useCallback("); - expect(sessionRouteSource).toContain("engineId: DEFAULT_ENGINE_ID"); + expect(sessionRouteSource).toContain("engineId: isBuiltInWorkspaceEngineId(activeEngineId) ? activeEngineId : DEFAULT_ENGINE_ID"); expect(sessionRouteSource).not.toMatch(/handleCreateInitialProjectTask[\s\S]{0,500}selectedWorkspace\?\.engineId/); expect(sessionRouteSource).toContain('name: t("session.untitled")'); expect(sessionRouteSource).toContain("surfaceProps.onSendDraft("); @@ -285,6 +285,14 @@ describe("sidebar projects", () => { ); }); + test("locks the project engine for new conversations while allowing initial engine selection", () => { + expect(sessionRouteSource).toContain("const projectEngineId = selectedWorkspace?.isDefault ? undefined : workspaceEngineId;"); + expect(sessionRouteSource).toContain("projectEngineId || newConversationEngineId"); + expect(sessionRouteSource).toContain("onSelectConversationEngine: projectEngineId ? undefined : setNewConversationEngineId"); + expect(sessionRouteSource).toMatch(/const engineId = \(workspace\.isDefault \? undefined : targetWorkspaceEngineId\)\s+\?\? conversationOptions\?\.engineId/); + expect(sessionPageSource).toContain('data-testid="conversation-project-engine">{engineLabel}'); + }); + test("loads the selected task directory before hydrating remaining project indexes", () => { expect(workspaceRouteStateSource).toContain( "const selectedEntry = cachedEntries.find((entry) => entry.workspaceId === nextWorkspaceId);", @@ -357,7 +365,7 @@ describe("sidebar projects", () => { expect(sidebarSource).toContain('project.status === "loading" && project.sessions.length === 0 && !isSelectedProject'); expect(sessionRouteSource).toContain("taskCreationInFlightRef.current.has(workspaceId)"); expect(sessionRouteSource).toMatch( - /endpoint\.client\.createSession\(\s*endpoint\.workspaceId,\s*undefined,\s*activeSelectedModel,\s*conversationOptions\?\.engineId/, + /endpoint\.client\.createSession\(\s*endpoint\.workspaceId,\s*undefined,\s*engineId === activeEngineId \? activeSelectedModel : undefined,\s*engineId/, ); expect(sessionRouteSource).not.toContain("workspaceConversation.create("); expect(sessionRouteSource).not.toContain("Give the click an immediate destination"); diff --git a/apps/server/src/ipollowork-workspace-config-store.ts b/apps/server/src/ipollowork-workspace-config-store.ts index 683e505d99..d27f6bbd63 100644 --- a/apps/server/src/ipollowork-workspace-config-store.ts +++ b/apps/server/src/ipollowork-workspace-config-store.ts @@ -91,8 +91,7 @@ async function workspaceConfigDb(config: ServerConfig): Promise> { - const db = await workspaceConfigDb(config); +function readWorkspaceConfig(db: iPolloWorkWorkspaceConfigDb, workspaceId: string): Record { const row = db.get(workspaceId); if (!row) return {}; try { @@ -102,14 +101,19 @@ export async function readiPolloWorkWorkspaceConfig(config: ServerConfig, worksp } } +export async function readiPolloWorkWorkspaceConfig(config: ServerConfig, workspaceId: string): Promise> { + return readWorkspaceConfig(await workspaceConfigDb(config), workspaceId); +} + export async function writeiPolloWorkWorkspaceConfig( config: ServerConfig, workspaceId: string, updater: (current: Record) => Record, ): Promise> { const db = await workspaceConfigDb(config); - const next = normalizeiPolloWorkWorkspaceConfig(updater(await readiPolloWorkWorkspaceConfig(config, workspaceId))); - db.upsert({ workspaceId, configJson: JSON.stringify(next), updatedAt: Date.now() }); + const current = readWorkspaceConfig(db, workspaceId); + const next = normalizeiPolloWorkWorkspaceConfig(updater(current)); + if (next !== current) db.upsert({ workspaceId, configJson: JSON.stringify(next), updatedAt: Date.now() }); return next; } diff --git a/apps/server/src/routes/work-items.ts b/apps/server/src/routes/work-items.ts index b8811d8f52..fa83bf2143 100644 --- a/apps/server/src/routes/work-items.ts +++ b/apps/server/src/routes/work-items.ts @@ -1,6 +1,7 @@ import { projectSessionExecutionFinishSchema, projectSessionExecutionStartSchema, + workAutomationModelSchema, workBoardConfigValueSchema, workItemCreateSchema, workItemUpdateSchema, @@ -22,6 +23,7 @@ import { updateWorkItem, writeWorkBoardConfig, readProjectSessionWorkItem, + setWorkAutomationModel, writeConversationWorkflow, listWorkTemplates, saveWorkTemplate, @@ -144,6 +146,16 @@ export function registerWorkItemRoutes(options: RegisterWorkItemRoutesOptions): return jsonResponse(await withConflictHandling(() => saveWorkTemplate(config, workspace, parsed.data)), 201); }); + addRoute(routes, "PUT", "/workspace/:id/work-automation-model", "client", async (ctx) => { + ensureWritable(config); + requireClientScope(ctx, "collaborator"); + const workspace = await resolveWorkspace(config, ctx.params.id); + const parsed = workAutomationModelSchema.safeParse(await readJsonBody(ctx.request)); + if (!parsed.success) throw schemaError("Scheduled task model is invalid", parsed.error.issues); + await setWorkAutomationModel(config, workspace, parsed.data); + return jsonResponse({ ok: true }); + }); + addRoute(routes, "POST", "/workspace/:id/work-items", "client", async (ctx) => { ensureWritable(config); requireClientScope(ctx, "collaborator"); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8636f44fde..d2889f697b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -148,7 +148,7 @@ import { finishProjectSessionExecution, listConversationSessionBindings, readProjectSessionWorkItem, - resolveProjectExecutionPlan, + resolveWorkItemAutomationExecutionPlan, startProjectSessionExecution, startWorkItemAutomationScheduler, workItemAutomationPrompt, @@ -1051,20 +1051,12 @@ export async function startServer(config: ServerConfig): Promise { config, dispatch: async (item) => { const workspace = await resolveWorkspace(config, item.workspaceId); - const requestedRuntime = { - engineId: workspace.engineId?.trim() || DEFAULT_ENGINE_ID, - model: item.automation?.model ?? null, - mode: null, - modelVariant: null, - }; - const plan = await resolveProjectExecutionPlan(config, workspace, { - agentId: item.assignee ?? undefined, - runtime: requestedRuntime, - }); - const session = await sessionRuntime.create(workspace, item.title, plan.runtime.model ? { + const plan = await resolveWorkItemAutomationExecutionPlan(config, workspace, item); + const model = { providerID: plan.runtime.model.providerId, modelID: plan.runtime.model.modelId, - } : undefined); + }; + const session = await sessionRuntime.create(workspace, item.title, model); const execution = { sessionId: session.id, ...plan, @@ -1074,12 +1066,7 @@ export async function startServer(config: ServerConfig): Promise { try { const promptedSessionId = await sessionRuntime.prompt(workspace, session.id, { text: workItemAutomationPrompt(item), - ...(execution.runtime.model ? { - model: { - providerID: execution.runtime.model.providerId, - modelID: execution.runtime.model.modelId, - }, - } : {}), + model, ...(execution.runtime.mode ? { mode: execution.runtime.mode } : {}), ...(execution.runtime.modelVariant ? { reasoningEffort: execution.runtime.modelVariant } : {}), system: projectExecutionSystemContext(execution), diff --git a/apps/server/src/work-items.test.ts b/apps/server/src/work-items.test.ts index 979f06e8c0..d7fb005594 100644 --- a/apps/server/src/work-items.test.ts +++ b/apps/server/src/work-items.test.ts @@ -24,7 +24,9 @@ import { finishProjectSessionExecution, listWorkItems, readWorkBoardConfig, + resolveWorkItemAutomationExecutionPlan, runDueWorkItemAutomationsOnce, + setWorkAutomationModel, workItemAutomationPrompt, startProjectSessionExecution, updateWorkItem, @@ -533,6 +535,28 @@ describe("work item store", () => { const unauthorized = await fetch(`${baseUrl}/work-items?workspaceId=project_one`); expect(unauthorized.status).toBe(401); const headers = { authorization: `Bearer ${config.token}`, "content-type": "application/json" }; + const automationModelUrl = `${baseUrl}/workspace/project_one/work-automation-model`; + const selection = { + engineId: DEFAULT_ENGINE_ID, + model: { providerId: "alibaba-cn", modelId: "qwen3.7-max" }, + modelVariant: null, + }; + expect((await fetch(automationModelUrl, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(selection), + })).status).toBe(401); + expect((await fetch(automationModelUrl, { + method: "PUT", headers, body: JSON.stringify({ ...selection, model: null }), + })).status).toBe(400); + expect((await fetch(automationModelUrl, { + method: "PUT", headers, body: JSON.stringify({ ...selection, engineId: DEEPSEEK_HARNESS_ENGINE_ID }), + })).status).toBe(409); + expect((await fetch(automationModelUrl, { + method: "PUT", headers, body: JSON.stringify(selection), + })).status).toBe(200); + expect(await readiPolloWorkWorkspaceConfig(config, "project_one")).toMatchObject({ + project, + workAutomationModel: selection, + }); const unbound = await fetch(`${baseUrl}/workspace/project_one/sessions/session_video/workflow`, { headers }); expect(await unbound.json()).toEqual({ item: null }); const templates = await fetch(`${baseUrl}/workspace/project_one/work-templates`, { headers }); @@ -657,6 +681,46 @@ describe("work item store", () => { } }); + test("scheduled work follows the latest composer model for its project and honors an explicit override", async () => { + const { config, root } = await testContext(); + const [workspace] = config.workspaces; + const item = await createWorkItem(config, workspace.id, { + title: "Follow the project model", + startAt: Date.now(), + automation: { enabled: true, recurrence: "daily" }, + }); + const plan = () => resolveWorkItemAutomationExecutionPlan(config, workspace, item); + await expect(plan()).rejects.toMatchObject({ code: "work_automation_model_required" }); + + const project = createDefaultProjectWorkspaceConfig({ engineId: DEFAULT_ENGINE_ID }); + const agentModel = { providerId: "openai", modelId: "gpt-agent-default" }; + project.agents[0].runtime.model = agentModel; + await writeiPolloWorkWorkspaceConfig(config, workspace.id, (current) => ({ ...current, project })); + expect((await plan()).runtime.model).toEqual(agentModel); + + const composer = { + engineId: DEFAULT_ENGINE_ID, + model: { providerId: "alibaba-cn", modelId: "qwen3.7-max" }, + modelVariant: "high", + }; + await setWorkAutomationModel(config, workspace, composer); + expect((await plan()).runtime).toMatchObject(composer); + const otherProject = { ...workspace, id: "project_two", path: join(root, "other") }; + await expect(resolveWorkItemAutomationExecutionPlan(config, otherProject, { ...item, workspaceId: otherProject.id })) + .rejects.toMatchObject({ code: "work_automation_model_required" }); + const changedEngine = { ...workspace, engineId: DEEPSEEK_HARNESS_ENGINE_ID }; + await expect(resolveWorkItemAutomationExecutionPlan(config, changedEngine, item)) + .rejects.toMatchObject({ code: "work_automation_model_required" }); + + const updated = { ...composer, model: { ...composer.model, modelId: "qwen3.7-flash" }, modelVariant: null }; + await setWorkAutomationModel(config, workspace, updated); + expect((await plan()).runtime).toMatchObject(updated); + const explicit = { providerId: "openai", modelId: "gpt-task-override" }; + expect((await resolveWorkItemAutomationExecutionPlan(config, workspace, { + ...item, automation: { enabled: true, recurrence: "daily", model: explicit }, + })).runtime).toMatchObject({ model: explicit, modelVariant: null }); + }); + test("persists, schedules, updates, and deletes project work", async () => { const { config } = await testContext(); try { diff --git a/apps/server/src/work-items.ts b/apps/server/src/work-items.ts index 2a3c5b0b05..3ca7c9bb85 100644 --- a/apps/server/src/work-items.ts +++ b/apps/server/src/work-items.ts @@ -8,6 +8,7 @@ import { workTemplateSaveSchema, workTemplateSchema, projectSessionExecutionSchema, + workAutomationModelSchema, workBoardConfigValueSchema, workItemAutomationSchema, workItemCreateSchema, @@ -16,6 +17,7 @@ import { type WorkBoardConfigValue, type WorkItem, type WorkItemAutomation, + type WorkAutomationModel, type WorkItemCreateInput, type WorkItemListResponse, type WorkItemUpdateInput, @@ -36,7 +38,7 @@ import { type ProjectAgent, type ProjectWorkspaceConfig, } from "@ipollowork/types/project-workspace"; -import { isHarnessWorkspaceEngineId } from "@ipollowork/types/workspace"; +import { DEFAULT_ENGINE_ID, isHarnessWorkspaceEngineId } from "@ipollowork/types/workspace"; import { ApiError, isApiError } from "./errors.js"; import { templateCategoryWorkKind } from "@ipollowork/types/templates"; @@ -600,7 +602,7 @@ async function projectWorkContext(config: ServerConfig, workspace: WorkspaceInfo ]); const parsed = projectWorkspaceConfigSchema.safeParse(stored.project); const project = parsed.success ? parsed.data : createDefaultProjectWorkspaceConfig(); - return { project, templates: [...builtinWorkTemplates, ...savedWorkTemplates(library)] }; + return { project, templates: [...builtinWorkTemplates, ...savedWorkTemplates(library)], workAutomationModel: stored.workAutomationModel }; } export async function listWorkTemplates(config: ServerConfig, workspace: WorkspaceInfo): Promise { @@ -984,27 +986,69 @@ function resolveProjectExecutionRuntime(input: { }; } -export async function resolveProjectExecutionPlan( +async function resolveProjectExecutionPlan( config: ServerConfig, workspace: WorkspaceInfo, - input: Pick, + input: Pick & { followComposerModel?: boolean }, ): Promise> { - const { project, templates } = await projectWorkContext(config, workspace); + const { project, templates, workAutomationModel } = await projectWorkContext(config, workspace); const template = templates.find((candidate) => candidate.id === "general"); if (!template) throw new Error("Default conversation template is missing"); const workflow = workflowFromTemplate(template, { source: "auto", project }); const agentId = workflow.config.orchestration.entryAgentId; const agent = workflow.config.agents.find((candidate) => candidate.id === agentId); if (!agent) throw new ApiError(409, "project_agent_missing", "The selected conversation Agent no longer exists"); + const preference = input.followComposerModel && !input.runtime.model + ? workAutomationModelSchema.safeParse(workAutomationModel) + : null; + const composer = preference?.success && preference.data.engineId === input.runtime.engineId ? preference.data : null; + const runtimeAgent = input.followComposerModel + ? project.agents.find((candidate) => candidate.id === project.orchestration.entryAgentId) ?? agent + : agent; return { projectRevision: project.revision, projectGoal: project.goal, agent, workflow, - runtime: resolveProjectExecutionRuntime({ engineId: input.runtime.engineId, agent, requested: input.runtime }), + runtime: resolveProjectExecutionRuntime({ + engineId: input.runtime.engineId, + agent: runtimeAgent, + requested: composer ? { ...input.runtime, model: composer.model, modelVariant: composer.modelVariant } : input.runtime, + }), }; } +export async function setWorkAutomationModel(config: ServerConfig, workspace: WorkspaceInfo, value: WorkAutomationModel): Promise { + if (value.engineId !== (workspace.engineId?.trim() || DEFAULT_ENGINE_ID)) { + throw new ApiError(409, "work_automation_engine_changed", "The project engine has changed"); + } + await writeiPolloWorkWorkspaceConfig(config, workspace.id, (current) => { + const saved = workAutomationModelSchema.safeParse(current.workAutomationModel); + if (saved.success && saved.data.engineId === value.engineId + && saved.data.model.providerId === value.model.providerId && saved.data.model.modelId === value.model.modelId + && saved.data.modelVariant === value.modelVariant) return current; + return { ...current, workAutomationModel: value }; + }); +} + +export async function resolveWorkItemAutomationExecutionPlan(config: ServerConfig, workspace: WorkspaceInfo, item: WorkItem) { + const plan = await resolveProjectExecutionPlan(config, workspace, { + agentId: item.assignee ?? undefined, + followComposerModel: true, + runtime: { + engineId: workspace.engineId?.trim() || DEFAULT_ENGINE_ID, + model: item.automation?.model ?? null, + mode: null, + modelVariant: null, + }, + }); + const model = plan.runtime.model; + if (!model) { + throw new ApiError(409, "work_automation_model_required", "Choose a model in the project chat or the scheduled task before it can run."); + } + return { ...plan, runtime: { ...plan.runtime, model } }; +} + export async function bindProjectSessionExecution( config: ServerConfig, workspace: WorkspaceInfo, diff --git a/evals/flows/conversation-workflows.flow.mjs b/evals/flows/conversation-workflows.flow.mjs index a0536583ac..16aeaa3ebe 100644 --- a/evals/flows/conversation-workflows.flow.mjs +++ b/evals/flows/conversation-workflows.flow.mjs @@ -92,7 +92,7 @@ export default { { name: "Start directly without preset setup", run: guarded(async (ctx) => { - await ctx.prove("A new conversation keeps engine selection and requires no work template or role setup", { + await ctx.prove("A new conversation uses the project engine and requires no work template or role setup", { voiceover: vo[0], action: async () => { ctx.assert(wid && Object.values(fixtures).every(f => f.sid && f.children?.length >= 2), "Provide existing OpenCode and Codex root/child fixture IDs"); @@ -114,7 +114,7 @@ export default { await ctx.waitFor("Boolean(document.querySelector('[data-testid=new-conversation-starter-composer-shell] [contenteditable=true]'))", { label: "new conversation composer" }); }, assert: async () => { - ctx.assert(await ctx.eval("Boolean(document.querySelector('[data-testid=conversation-engine-picker]')) && !document.querySelector('[data-testid=work-template-picker]') && !document.querySelector('[data-testid=project-agent-inspector]')"), "Engine choice remains; preset selection is absent"); + ctx.assert(await ctx.eval("Boolean(document.querySelector('[data-testid=conversation-project-engine]')) && !document.querySelector('[data-testid=conversation-engine-picker], [data-testid=work-template-picker], [data-testid=project-agent-inspector]')"), "The project engine is displayed without engine or preset selection"); }, screenshot: { name: "direct-start", requireText: ["OpenCode"], rejectText: ["Something went wrong"] }, }); diff --git a/evals/flows/opencode-free-inference.flow.mjs b/evals/flows/opencode-free-inference.flow.mjs index 2a9795f6dc..d1c4dd6817 100644 --- a/evals/flows/opencode-free-inference.flow.mjs +++ b/evals/flows/opencode-free-inference.flow.mjs @@ -17,18 +17,16 @@ export default { kind: "user-facing", steps: [ ...(engines.some(engine => engine.engineId === 'opencode') ? [{ - name: 'Switching from Codex defaults to a live free OpenCode model without selecting one', + name: 'Using a live free default model in an OpenCode project', run: async ctx => { await ctx.waitFor('Boolean(window.__ipolloworkControl)'); const { id: workspaceId } = await ensureEngineWorkspace(ctx, { - project: 'codex', engineId: 'codex-harness', label: 'Codex Harness', name: '免费默认模型切换验证', + project: 'open', engineId: 'opencode', label: 'OpenCode', name: '免费默认模型验证', }); await ctx.prove('OpenCode chooses its real free default and returns a reply without a model click', { - voiceover: '在 Codex 项目中新建任务,切换 OpenCode 后不手动选择模型,免费默认模型直接回复。', + voiceover: '在 OpenCode 项目中新建任务,使用默认的免费模型直接获得回复。', action: async () => { await ctx.navigateHash(`/workspace/${workspaceId}/session`); - await ctx.trustedClick('[data-testid="conversation-engine-picker"]'); - await ctx.clickText('OpenCode', { selector: '[role="option"]' }); await ctx.waitFor(`Array.from(document.querySelectorAll('button')).some(b => /切换模型|Change model/.test(b.getAttribute('aria-label') || '') && b.textContent.includes('Big Pickle'))`, { timeoutMs: 70_000 }); await selectAndInvokeFreeModel(ctx, { project: 'open', engineId: 'opencode', label: 'OpenCode' }, { model: 'Big Pickle', token: 'OPENCODE_DEFAULT_FREE_OK' }); }, diff --git a/evals/flows/opencode-model-persistence.flow.mjs b/evals/flows/opencode-model-persistence.flow.mjs new file mode 100644 index 0000000000..5911730ccc --- /dev/null +++ b/evals/flows/opencode-model-persistence.flow.mjs @@ -0,0 +1,62 @@ +import { request } from "./native-agent-results.flow.mjs"; + +const modelButton = 'button:is([aria-label^="切换模型"], [aria-label^="Change model"])'; +const modelId = "gpt-5.6-sol"; +const savedSelection = `JSON.parse(localStorage.getItem('ipollowork.preferences') || '{}').enginePreferences?.opencode?.model`; +const isGptSelected = `document.querySelector(${JSON.stringify(modelButton)})?.innerText.includes('GPT 5.6')`; + +export default { + id: "opencode-model-persistence", + title: "An explicit OpenCode model selection survives a window reload", + kind: "user-facing", + preserveTheme: true, + precondition: async (ctx) => { + await ctx.waitFor("Boolean(window.__ipolloworkControl)"); + const workspaceId = await ctx.eval("location.hash.split('/')[2]"); + const data = await request(ctx, "/workspaces"); + const openCodeProject = (data.items ?? data.workspaces ?? []).some(project => project.id === workspaceId && project.engineId === "opencode"); + const idle = await ctx.eval(`!document.querySelector('button:is([aria-label="停止"], [aria-label="Stop"])') + && ![...document.querySelectorAll('[contenteditable=true][data-lexical-editor=true]')].some(editor => editor.innerText.trim())`); + return openCodeProject && idle ? null : "Open an idle OpenCode conversation with GPT-5.6 Sol available."; + }, + steps: [{ + name: "Select GPT-5.6 Sol and reload the desktop window", + async run(ctx) { + await ctx.prove("The chosen GPT-5.6 Sol remains selected after reloading OpenCode", { + voiceover: "在 OpenCode 里选定 GPT-5.6 Sol,再刷新窗口,输入框仍显示选定的模型。", + action: async () => { + const directoryButton = `Array.from(document.querySelectorAll('[role="dialog"] button')) + .find(button => /切换模型|Change model/.test(button.textContent.trim()))`; + if (!await ctx.eval(`Boolean(${directoryButton})`)) { + await ctx.trustedClick(modelButton); + } + await ctx.waitFor(`(() => { + const button = ${directoryButton}; + if (!button) return false; + button.click(); + return true; + })()`, { label: "open the model directory" }); + await ctx.waitFor(`(() => { + const option = Array.from(document.querySelectorAll('[data-slot="command-item"]')) + .find(item => item.textContent.includes('GPT-5.6 Sol') && !item.textContent.includes('Sol Fast') && !item.hasAttribute('data-disabled')); + if (!option) return false; + option.click(); + return true; + })()`, { label: "GPT-5.6 Sol is available in the model picker" }); + await ctx.waitFor(`(${savedSelection})?.modelID === ${JSON.stringify(modelId)} && ${isGptSelected}`, { label: "explicit choice saved for OpenCode" }); + const documentStartedAt = await ctx.eval("performance.timeOrigin"); + await ctx.client.send("Page.reload", { ignoreCache: true }); + await ctx.waitFor(`performance.timeOrigin !== ${documentStartedAt} && Boolean(window.__ipolloworkControl) && ${isGptSelected}`, { + label: "OpenCode loaded with its saved model", timeoutMs: 45_000, + }); + }, + assert: async () => { + const selection = await ctx.eval(savedSelection); + ctx.assert(selection?.providerID === "openai" && selection?.modelID === modelId, "OpenCode rehydrated the exact provider and model choice"); + ctx.assert(await ctx.eval(isGptSelected), "The composer shows GPT-5.6 after reload"); + }, + screenshot: { name: "model-after-reload", requireText: ["GPT 5.6"] }, + }); + }, + }], +}; diff --git a/evals/flows/opencode-stop-resume.flow.mjs b/evals/flows/opencode-stop-resume.flow.mjs new file mode 100644 index 0000000000..316340af09 --- /dev/null +++ b/evals/flows/opencode-stop-resume.flow.mjs @@ -0,0 +1,92 @@ +import { open, request } from "./native-agent-results.flow.mjs"; + +const firstPrompt = "请只用纯文本逐行输出数字 1 到 500,不调用工具。"; +const reply = "续跑验证成功"; +const nextPrompt = `继续。请只回复「${reply}」,不调用工具。`; +const editor = '[contenteditable="true"][data-lexical-editor="true"]'; +const sendButton = ':is(button[title="Run task"], button[title="运行任务"]):not(:disabled)'; +const stopButton = 'button:is([aria-label="Stop"], [aria-label="停止"])'; + +async function send(ctx, surface, text) { + const selector = JSON.stringify(`${surface} ${editor}`); + await ctx.waitFor(`Boolean(document.querySelector(${selector}))`); + await ctx.eval(`document.querySelector(${selector}).focus()`); + await ctx.client.send("Input.insertText", { text }); + await ctx.trustedClick(`${surface} ${sendButton}`); +} + +export default { + id: "opencode-stop-resume", + title: "A new OpenCode prompt shows its reply after Stop", + kind: "user-facing", + preserveTheme: true, + precondition: async (ctx) => { + await ctx.waitFor("Boolean(window.__ipolloworkControl)"); + const id = await ctx.eval("location.hash.split('/')[2]"); + const data = await request(ctx, "/workspaces"); + return (data.items ?? data.workspaces ?? []).some(project => project.id === id && project.engineId === "opencode") + ? null : "Open a local OpenCode project with a working model before running this proof."; + }, + steps: [{ + name: "Stop a running prompt and send a follow-up", + async run(ctx) { + const previousHash = await ctx.eval("location.hash"); + const wid = previousHash.split("/")[2]; + const { item: { id: sid } } = await request(ctx, `/workspace/${wid}/sessions`, "POST", { + title: "停止后继续验证", engineId: "opencode", + }); + const path = `/workspace/${wid}/sessions/${sid}`; + const surface = `[data-session-surface-id=${JSON.stringify(sid)}]`; + const stopped = `Array.from(document.querySelectorAll(${JSON.stringify(surface)})).some(el => /任务已中断|Run interrupted/.test(el.innerText))`; + const response = `Array.from(document.querySelectorAll(${JSON.stringify(`${surface} [data-message-role="assistant"]`)})).some(el => el.innerText.includes(${JSON.stringify(reply)}))`; + + try { + await open(ctx, { wid, sid }); + await ctx.prove("After stopping the previous turn, the next turn displays its own reply and settles", { + voiceover: "停止正在输出的任务,再发送继续。新回复会显示在同一对话中,任务结束后输入框可以再次发送。", + action: async () => { + await send(ctx, surface, firstPrompt); + await ctx.waitFor(`Boolean(document.querySelector(${JSON.stringify(`${surface} ${stopButton}`)})) + && window.__owReactQueryClient?.getQueryData(${JSON.stringify(["react-session-transcript", wid, sid])}) + ?.some(message => message.role === 'assistant')`, { + label: "OpenCode created a running assistant message", timeoutMs: 25_000, + }); + await ctx.trustedClick(`${surface} ${stopButton}`); + await ctx.waitFor(`${stopped} && !document.querySelector(${JSON.stringify(`${surface} ${stopButton}`)})`, { label: "stop has settled" }); + await send(ctx, surface, nextPrompt); + await ctx.waitFor(`${response} && !document.querySelector(${JSON.stringify(`${surface} ${stopButton}`)})`, { + label: "resumed response is visible and idle", timeoutMs: 40_000, + }); + }, + assert: async () => { + const { item } = await request(ctx, `${path}/snapshot`); + const [first, next] = item.messages.filter(message => message.info.role === "user"); + ctx.assert(item.messages.some(message => message.info.parentID === first?.info.id && message.info.error?.name === "MessageAbortedError"), + "OpenCode recorded the first turn as interrupted"); + ctx.assert(item.status.type === "idle" && item.messages.some(message => message.info.parentID === next?.info.id + && message.info.time.completed && !message.info.error && message.parts.some(part => part.type === "text" && part.text.includes(reply))), + "The visible reply belongs to the completed follow-up turn"); + }, + screenshot: { name: "resumed-response", requireText: [reply], hashIncludes: `/session/${sid}` }, + }); + } finally { + const { item } = await request(ctx, `${path}/snapshot`); + const view = await ctx.eval(`({ + selected: location.hash.endsWith(${JSON.stringify(`/session/${sid}`)}), + status: window.__owReactQueryClient?.getQueryData(${JSON.stringify(["react-session-status", wid, sid])})?.type, + visibleReply: ${response}, + visibleStop: Boolean(document.querySelector(${JSON.stringify(`${surface} ${stopButton}`)})), + })`); + const native = item.messages.map(message => ({ role: message.info.role, error: message.info.error?.name, + reply: message.parts.some(part => part.type === "text" && part.text.includes(reply)) })); + ctx.log(`Proof session ${JSON.stringify({ nativeStatus: item.status.type, native, view })}`); + if (view.selected) await ctx.navigateHash(previousHash); + const prompts = item.messages.filter(message => message.info.role === "user") + .map(message => message.parts.filter(part => part.type === "text").map(part => part.text).join("\n")); + if (item.status.type === "idle" && prompts.length <= 2 && prompts.every((text, index) => text === [firstPrompt, nextPrompt][index])) { + await request(ctx, path, "DELETE"); + } + } + }, + }], +}; diff --git a/evals/flows/project-engine-lock.flow.mjs b/evals/flows/project-engine-lock.flow.mjs new file mode 100644 index 0000000000..74129f8dbf --- /dev/null +++ b/evals/flows/project-engine-lock.flow.mjs @@ -0,0 +1,55 @@ +const projectRow = '[data-testid="project-row"][data-selected="true"]'; +const engineMenuInfo = '[data-testid="project-engine-menu-info"]'; +const engineLabel = '[data-testid="conversation-project-engine"]'; + +export default { + id: "project-engine-lock", + title: "New conversations inherit the project engine", + kind: "user-facing", + preserveTheme: true, + precondition: async (ctx) => { + await ctx.waitFor("Boolean(window.__ipolloworkControl)"); + return await ctx.eval(`Boolean(document.querySelector(${JSON.stringify(projectRow)}))`) + ? null : "Open a named project with an installed engine before running this proof."; + }, + steps: [{ + name: "Open a new conversation in the current project", + run: async (ctx) => { + const previousHash = await ctx.eval("location.hash"); + const projectId = await ctx.eval(`document.querySelector(${JSON.stringify(projectRow)}).dataset.projectId`); + const projectSelector = `[data-project-id=${JSON.stringify(projectId)}]`; + + await ctx.trustedClick(`[data-testid="project-actions-menu"]${projectSelector}`); + const engine = await ctx.waitFor(`document.querySelector(${JSON.stringify(engineMenuInfo)})?.textContent.trim()`); + await ctx.client.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await ctx.client.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await ctx.waitFor(`!document.querySelector(${JSON.stringify(engineMenuInfo)})`); + + try { + await ctx.prove("The new conversation shows the project's engine without a switcher", { + voiceover: "在项目里打开新对话,输入框上方显示项目绑定的引擎名称。", + action: async () => { + await ctx.trustedClick(`[data-testid="project-new-conversation-button"]${projectSelector}`); + await ctx.waitFor(`Boolean(document.querySelector(${JSON.stringify(engineLabel)}))`); + }, + assert: async () => { + const state = await ctx.eval(`(() => { + const label = document.querySelector(${JSON.stringify(engineLabel)}); + return { + engine: label?.textContent.trim(), + interactive: label?.matches('button, [role], [tabindex]'), + hasPicker: Boolean(document.querySelector('[data-testid="conversation-engine-picker"]')), + hasComposer: Boolean(document.querySelector('[data-testid="new-conversation-starter-composer-shell"] [contenteditable="true"]')), + }; + })()`); + ctx.assert(state.engine === engine && !state.interactive && !state.hasPicker && state.hasComposer, + "The composer shows the same engine as the project menu and has no engine switcher"); + }, + screenshot: { name: "project-engine", requireText: [engine], hashIncludes: `/workspace/${projectId}/session` }, + }); + } finally { + await ctx.navigateHash(previousHash.slice(1)); + } + }, + }], +}; diff --git a/evals/flows/project-task-automation.flow.mjs b/evals/flows/project-task-automation.flow.mjs new file mode 100644 index 0000000000..742aa6f73f --- /dev/null +++ b/evals/flows/project-task-automation.flow.mjs @@ -0,0 +1,72 @@ +import { click, request } from "./native-agent-results.flow.mjs"; + +const tasksTab = '[data-testid="session-header-work-tasks"]'; +const sheet = '[data-testid="work-item-sheet"]'; +const automationSwitch = '[data-testid="work-item-automation"] [role="switch"]'; + +export default { + id: "project-task-automation", + title: "Schedule automatic execution from the project task board", + kind: "user-facing", + preserveTheme: true, + steps: [ + { + name: "Create and reopen a scheduled project task", + run: async (ctx) => { + await ctx.waitFor("Boolean(window.__ipolloworkControl) && !document.querySelector('[data-testid=startup-logo-animation]')"); + const [workspaceId, sessionId] = await ctx.eval( + "location.hash.match(/\\/workspace\\/([^/]+)\\/session\\/([^/?]+)/)?.slice(1) ?? []", + ); + ctx.assert(workspaceId && sessionId, "Open a project conversation before running this flow."); + ctx.assert(await ctx.eval("!document.querySelector('[role=dialog]')"), "Close any open editor before running this flow."); + const originalTab = await ctx.eval("document.querySelector('[data-testid^=session-header-][aria-current=page]')?.dataset.testid"); + const title = `自动执行验证 ${Date.now().toString(36)}`; + const startDate = await ctx.eval(`(() => { + const date = new Date(); + date.setDate(date.getDate() + 2); + return new Intl.DateTimeFormat("sv-SE").format(date); + })()`); + const path = `/work-items?workspaceId=${encodeURIComponent(workspaceId)}&sessionId=${encodeURIComponent(sessionId)}`; + const findTask = async () => (await request(ctx, path)).items.find(item => item.title === title); + + try { + await ctx.prove("A new project task can save automatic execution", { + voiceover: "我在项目看板新建任务,设置开始时间并开启自动执行,保存后再打开确认设置已生效。", + action: async () => { + await ctx.waitFor(`(() => { + const tab = document.querySelector(${JSON.stringify(tasksTab)}); + if (tab?.getAttribute("aria-current") === "page") return true; + tab?.click(); + return false; + })()`, { label: "project Tasks tab selected" }); + await ctx.clickText("新建任务", { selector: '[data-testid="work-center"] > header button:not(:disabled)' }); + await ctx.fill("#work-item-title", title); + await ctx.fill("#work-item-start-date", startDate); + await click(ctx, automationSwitch); + await ctx.clickText("保存", { selector: `${sheet} button` }); + await ctx.waitFor(`!document.querySelector(${JSON.stringify(sheet)})`, { label: "saved task editor closed" }); + await ctx.clickText(title, { selector: '[data-testid="project-board"] article > button' }); + await ctx.waitFor(`document.querySelector(${JSON.stringify(automationSwitch)})?.getAttribute("aria-checked") === "true"`, { + label: "saved automatic execution switch is on", + }); + }, + assert: async () => { + const item = await findTask(); + ctx.assert(item?.startAt > Date.now(), "The saved task starts in the future."); + ctx.assert(item.automation?.enabled && item.automation.recurrence === "once", "Automatic execution is saved on the conversation's task."); + ctx.assert(await ctx.eval(`document.querySelector("#work-item-start-date").value === ${JSON.stringify(startDate)}`), "The chosen start date is restored."); + }, + screenshot: { name: "project-task-automatic-execution", requireText: [title, "自动执行", "重复方式"] }, + }); + } finally { + const item = await findTask(); + if (item) { + await request(ctx, `/workspace/${workspaceId}/work-items/${item.id}?version=${item.version}`, "DELETE"); + } + await ctx.eval(`document.querySelector('${sheet} [data-slot="sheet-close"]')?.click(); true`); + if (originalTab) await click(ctx, `[data-testid="${originalTab}"]`); + } + }, + }, + ], +}; diff --git a/evals/flows/provider-oauth-recovery.flow.mjs b/evals/flows/provider-oauth-recovery.flow.mjs new file mode 100644 index 0000000000..b820d268e9 --- /dev/null +++ b/evals/flows/provider-oauth-recovery.flow.mjs @@ -0,0 +1,72 @@ +const retryLabel = "重新授权"; +const unconfirmed = "应用尚未确认这次授权"; +const dialogButtons = '[role="dialog"] button:not(:disabled)'; + +export default { + id: "provider-oauth-recovery", + title: "Restart authorization after an unconfirmed browser callback", + kind: "user-facing", + preserveTheme: true, + steps: [{ + name: "Restart OpenAI authorization from the recovery action", + async run(ctx) { + await ctx.waitFor("Boolean(window.__ipolloworkControl) && !document.querySelector('[data-testid=startup-logo-animation]')"); + ctx.assert(await ctx.eval("!document.querySelector('[role=dialog]')"), "Close the existing dialog before running this flow."); + // Mount the real modal with synthetic callbacks so the proof does not + // open a third-party sign-in page or write real provider credentials. + await ctx.eval(`(async () => { + const entry = await (await fetch("/src/index.react.tsx")).text(); + const reactUrl = entry.match(/"([^"]+\\/react\\.js\\?[^"]+)"/)?.[1]; + const domUrl = entry.match(/"([^"]+\\/react-dom_client\\.js\\?[^"]+)"/)?.[1]; + if (!reactUrl || !domUrl) throw new Error("The desktop Vite entry did not expose the React imports"); + const [{ default: React }, dom, { default: Modal }] = await Promise.all([ + import(reactUrl), + import(domUrl), + import("/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx"), + ]); + const element = document.createElement("div"); + document.body.append(element); + const root = (dom.createRoot ?? dom.default.createRoot)(element); + const fixture = { authorize: 0, callback: 0, readOnlyPolls: [] }; + window.__providerOAuthRecovery = { fixture, dispose: () => { root.unmount(); element.remove(); } }; + root.render(React.createElement(Modal, { + open: true, loading: false, submitting: false, error: null, + providers: [{ id: "openai", name: "OpenAI", env: [] }], + connectedProviderIds: [], + authMethods: { openai: [{ methodIndex: 0, type: "oauth", label: "ChatGPT Pro/Plus (browser)" }] }, + onSelect: async () => { + fixture.authorize++; + return { methodIndex: 0, authorization: { url: "", method: "auto", instructions: "" } }; + }, + onSubmitOAuth: async () => { + fixture.callback++; + return { connected: false, pending: true }; + }, + onRefreshProviders: async options => { fixture.readOnlyPolls.push(options?.skipSharedImport === true); }, + onClose: () => {}, + })); + return true; + })()`, { awaitPromise: true }); + try { + await ctx.prove("An unconfirmed callback offers a fresh browser authorization", { + voiceover: "应用确认不到浏览器授权时,会提示重新授权,点一下即可发起新的登录。", + action: async () => { + await ctx.clickText("OpenAI", { selector: dialogButtons }); + await ctx.clickText("ChatGPT Pro/Plus (browser)", { selector: dialogButtons }); + await ctx.waitForText(unconfirmed); + await ctx.clickText(retryLabel, { selector: dialogButtons }); + await ctx.waitFor("window.__providerOAuthRecovery.fixture.callback === 2", { label: "fresh OAuth callback request" }); + }, + assert: async () => { + const state = await ctx.eval("window.__providerOAuthRecovery.fixture"); + ctx.assert(state.authorize === 2 && state.callback === 2, "Each attempt starts a new authorization and requests its callback once."); + ctx.assert(state.readOnlyPolls.length > 0 && state.readOnlyPolls.every(Boolean), "Authorization polling does not import credentials."); + }, + screenshot: { name: "provider-oauth-retry", requireText: [unconfirmed, retryLabel] }, + }); + } finally { + await ctx.eval("window.__providerOAuthRecovery.dispose(); delete window.__providerOAuthRecovery; true"); + } + }, + }], +}; diff --git a/evals/flows/scheduled-task-project-model.flow.mjs b/evals/flows/scheduled-task-project-model.flow.mjs new file mode 100644 index 0000000000..5a40b4b9dd --- /dev/null +++ b/evals/flows/scheduled-task-project-model.flow.mjs @@ -0,0 +1,89 @@ +import { setTimeout } from "node:timers/promises"; +import { click, request } from "./native-agent-results.flow.mjs"; + +const composerModel = 'button:is([aria-label^="切换模型"], [aria-label^="Change model"])'; +const modelId = "qwen3.7-max"; +const providerId = "alibaba-cn"; +const reply = "定时模型验证通过"; + +export default { + id: "scheduled-task-project-model", + title: "Scheduled tasks use the model shown in the project composer", + kind: "user-facing", + preserveTheme: true, + steps: [{ + name: "Run a scheduled task that follows the project model", + async run(ctx) { + await ctx.waitFor("Boolean(window.__ipolloworkControl) && !document.querySelector('[data-testid=startup-logo-animation]')"); + const originalHash = await ctx.eval("location.hash"); + const source = process.env.IPOLLOWORK_AUTOMATION_SOURCE_ROUTE ?? originalHash; + const workspaceId = source.match(/\/workspace\/([^/]+)\/session\/[^/]+/)?.[1]; + ctx.assert(workspaceId, "Open a project conversation with Alibaba Qwen3.7 Max selected."); + const workspacePath = `/workspace/${encodeURIComponent(workspaceId)}`; + const itemsPath = `/work-items?workspaceId=${encodeURIComponent(workspaceId)}`; + const title = `定时任务模型验证 ${Date.now().toString(36)}`; + const findTask = async () => (await request(ctx, itemsPath)).items.find( + item => item.title === title && !item.execution, + ); + let snapshot; + try { + await ctx.prove("Follow project model dispatches to Alibaba Qwen3.7 Max", { + voiceover: "项目输入框显示 Qwen3.7 Max,选择跟随项目智能体的定时任务也用这个模型完成回复。", + action: async () => { + await ctx.navigateHash(source); + await ctx.waitFor("Boolean(document.querySelector('[data-testid=session-header-work-conversation]'))"); + await click(ctx, "[data-testid=session-header-work-conversation]"); + await ctx.waitFor( + `/qwen3\\.7 max/i.test(document.querySelector(${JSON.stringify(composerModel)})?.textContent ?? "")`, + { label: "Qwen3.7 Max is displayed in the project composer" }, + ); + const selection = await ctx.eval("JSON.parse(localStorage.getItem('ipollowork.preferences') || '{}').enginePreferences?.opencode?.model"); + ctx.assert(selection?.providerID === providerId && selection?.modelID === modelId, "The project uses the Alibaba route for Qwen3.7 Max."); + const task = await request(ctx, `${workspacePath}/work-items`, "POST", { + title, + description: `只回复“${reply}”,不需要使用任何工具。`, + startAt: Date.now() - 1_000, + automation: { enabled: true, recurrence: "once", model: null }, + }); + ctx.assert(task.automation?.model === null, "The scheduled task has no explicit model override."); + const deadline = Date.now() + 65_000; + while (Date.now() < deadline) { + const item = await findTask(); + if (item?.status === "failed") throw new Error(item.automationLastError || "Scheduled task failed"); + if (item?.status === "review" && item.automationLastSessionId) { + const sessionId = item.automationLastSessionId; + snapshot = (await request(ctx, `${workspacePath}/sessions/${sessionId}/snapshot`)).item; + await ctx.navigateHash(`${workspacePath}/session/${sessionId}`); + break; + } + await setTimeout(1_500); + } + ctx.assert(snapshot, "The scheduled task completed and created a conversation."); + await ctx.expectText(reply); + }, + assert: async () => { + const assistant = snapshot.messages.find(({ info }) => info.role === "assistant"); + ctx.assert(assistant?.info.providerID === providerId && assistant?.info.modelID === modelId, + "The actual scheduled assistant response used the same provider and model as the composer."); + ctx.assert(assistant.parts.some(part => part.type === "text" && part.text.includes(reply)), "The model returned the expected reply."); + ctx.output("Actual scheduled model", `${assistant.info.providerID}/${assistant.info.modelID}`); + }, + screenshot: { name: "scheduled-task-project-model", requireText: [title, reply] }, + }); + } finally { + await ctx.navigateHash(originalHash); + const item = await findTask(); + if (item) await request(ctx, `${workspacePath}/work-items/${item.id}?version=${item.version}`, "DELETE"); + if (item?.automationLastSessionId) { + try { + await request(ctx, `${workspacePath}/sessions/${item.automationLastSessionId}?deleteFiles=true`, "DELETE"); + } catch (error) { + if (!/timed out|TimeoutError/i.test(String(error))) throw error; + const { items } = await request(ctx, `${workspacePath}/sessions?limit=200`); + if (items.some(session => session.id === item.automationLastSessionId)) throw error; + } + } + } + }, + }], +}; diff --git a/evals/voiceovers/conversation-workflows.md b/evals/voiceovers/conversation-workflows.md index 7a52625368..17b24a9b8e 100644 --- a/evals/voiceovers/conversation-workflows.md +++ b/evals/voiceovers/conversation-workflows.md @@ -1,6 +1,6 @@ # conversation-workflows — 直接开始,展示实际协作 -1. 新建对话时直接输入需求,保留引擎选择,不再要求选择工作模板或配置预设角色。 +1. 在项目里新建对话时,页面显示项目绑定的引擎。直接输入需求即可开始。 2. 发送一条简单消息,获得引擎的真实回复;当前概览顶部只显示一行对话标题,不重复整段发送内容;保留任务和用量布局,只展示主智能体。 diff --git a/packages/types/src/work-items.ts b/packages/types/src/work-items.ts index e7205b43f3..d2b8754c5d 100644 --- a/packages/types/src/work-items.ts +++ b/packages/types/src/work-items.ts @@ -83,6 +83,12 @@ export const projectSessionExecutionRuntimeSchema = z.object({ modelVariant: z.string().trim().min(1).max(64).nullable(), }); +export const workAutomationModelSchema = projectSessionExecutionRuntimeSchema.pick({ + engineId: true, + modelVariant: true, +}).extend({ model: projectAgentModelSchema }); +export type WorkAutomationModel = z.infer; + export const conversationWorkKindSchema = z.enum(["general", "video", "design", "development", "research", "document"]); export const conversationWorkflowStageSchema = z.object({ id: z.string().trim().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/), From 4fbafd7f471b8075deb99434241661cc8bfcd583 Mon Sep 17 00:00:00 2001 From: "jianyu.zhang" <3193941960@qq.com> Date: Wed, 11 Nov 2026 16:45:42 +0800 Subject: [PATCH 2/3] chore(release): prepare v0.50.35 --- apps/app/package.json | 2 +- apps/desktop/package.json | 2 +- apps/orchestrator/package.json | 4 ++-- apps/server/package.json | 2 +- pnpm-lock.yaml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/app/package.json b/apps/app/package.json index 18868ad54c..aa43da417b 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,7 +1,7 @@ { "name": "@ipollowork/app", "private": true, - "version": "0.50.34", + "version": "0.50.35", "type": "module", "scripts": { "test": "bun test --isolate tests/", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0352960af1..0039f63c25 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@ipollowork/desktop", "desktopName": "@ipolloworkdesktop.desktop", "private": true, - "version": "0.50.34", + "version": "0.50.35", "description": "iPolloWork desktop shell", "author": { "name": "iPolloWork", diff --git a/apps/orchestrator/package.json b/apps/orchestrator/package.json index c43a44b1c3..c116d8a1cb 100644 --- a/apps/orchestrator/package.json +++ b/apps/orchestrator/package.json @@ -1,6 +1,6 @@ { "name": "ipollowork-orchestrator", - "version": "0.50.34", + "version": "0.50.35", "description": "iPolloWork host orchestrator for opencode + iPolloWork server", "private": true, "type": "module", @@ -46,7 +46,7 @@ "@opencode-ai/sdk": "1.18.34", "@opentui/core": "0.1.77", "@opentui/solid": "0.1.77", - "ipollowork-server": "0.50.34", + "ipollowork-server": "0.50.35", "solid-js": "1.9.9" }, "devDependencies": { diff --git a/apps/server/package.json b/apps/server/package.json index 1c002b3339..cac883b564 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "ipollowork-server", - "version": "0.50.34", + "version": "0.50.35", "description": "Filesystem-backed API for iPolloWork remote clients", "type": "module", "bin": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 778477c79e..fcc2395075 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,7 +319,7 @@ importers: specifier: 0.1.77 version: 0.1.77(solid-js@1.9.9)(stage-js@1.0.0-alpha.17)(typescript@5.9.3)(web-tree-sitter@0.25.10) ipollowork-server: - specifier: 0.50.34 + specifier: 0.50.35 version: link:../server solid-js: specifier: 1.9.9 From b77d9122d775dc9413a6ac759824644c71bc0a3a Mon Sep 17 00:00:00 2001 From: "jianyu.zhang" <3193941960@qq.com> Date: Wed, 11 Nov 2026 16:59:49 +0800 Subject: [PATCH 3/3] Align the release motion catalog and stabilize voiceover CI --- .../bundled-templates/core-v1-video-motion-component-map.md | 6 ++---- apps/server/src/extensions/media-center.test.ts | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/bundled-templates/core-v1-video-motion-component-map.md b/apps/server/bundled-templates/core-v1-video-motion-component-map.md index 46f0c014ba..9ed98f2cb1 100644 --- a/apps/server/bundled-templates/core-v1-video-motion-component-map.md +++ b/apps/server/bundled-templates/core-v1-video-motion-component-map.md @@ -60,11 +60,11 @@ Use the same transition for repeated continuity when that supports the story. Va | Primary pattern | Default attention path | Recipe | Native components | | --- | --- | --- | ---: | | `progressive-build` | Reveal ordered ideas, layers, or a statement in meaningful beats. | [progressive-build.md](progressive-build.md) | 0 | -| `focus-transfer` | Move attention among peers while their relationship remains visible. | [focus-transfer.md](focus-transfer.md) | 4 | +| `focus-transfer` | Move attention among peers while their relationship remains visible. | [focus-transfer.md](focus-transfer.md) | 3 | | `path-journey` | Advance a route, workflow, dependency, or chronology. | [path-journey.md](path-journey.md) | 5 | | `state-transformation` | Make a before/after, correction, resolution, or completion legible. | [state-transformation.md](state-transformation.md) | 1 | | `data-accumulation` | Build quantitative evidence, rank, distribution, or signal over time. | [data-accumulation.md](data-accumulation.md) | 7 | -| `asset-exploration` | Guide attention through media, an interface, or a device. | [asset-exploration.md](asset-exploration.md) | 8 | +| `asset-exploration` | Guide attention through media, an interface, or a device. | [asset-exploration.md](asset-exploration.md) | 7 | | `montage` | — | [montage.md](montage.md) | overlay | | `camera-journey` | — | [camera-journey.md](camera-journey.md) | overlay | | `dialogue` | — | [dialogue.md](dialogue.md) | overlay | @@ -87,7 +87,6 @@ Move attention among peers while their relationship remains visible. | `device-carousel` | body | seekable | Visit three screen images with independent depth planes, focus transfers and an overview landing. | | `interface-state-board` | body | seekable | Compare important UI states before a walkthrough. | | `split-screen` | body | seekable | A balanced two-panel media layout for parallel stories, perspectives or before-and-after framing. | -| `workflow-demo-cards` | body | legacy metadata | Show up to four workflow states as cards with one highlighted state. | ## path-journey @@ -129,7 +128,6 @@ Guide attention through media, an interface, or a device. | Component | Typical phase | Registry contract | Intended use | | --- | --- | --- | --- | -| `browser-feature-tour` | body | legacy metadata | Present a browser product feature with named hotspots. | | `browser-walkthrough` | body | seekable | A browser UI walkthrough with editable steps, selected state and deterministic cursor click. | | `device-mockup` | body | seekable | A lightweight themed phone or laptop mockup with optional screen media. | | `media-hero` | body | seekable | A cinematic media-first hero with a readable headline and optional image source. | diff --git a/apps/server/src/extensions/media-center.test.ts b/apps/server/src/extensions/media-center.test.ts index 4a3bee0b5c..da51182cc0 100644 --- a/apps/server/src/extensions/media-center.test.ts +++ b/apps/server/src/extensions/media-center.test.ts @@ -1065,6 +1065,7 @@ describe("Media Center extension", () => { const mp3 = Buffer.concat(Array.from({ length: 100 }, () => frame)); let activeSynthesisRequests = 0; let maximumSynthesisRequests = 0; + const synthesisRequestsStarted = Promise.withResolvers(); const synthesisInputs: Array> = []; globalThis.fetch = (async (input, init) => { const url = String(input); @@ -1073,7 +1074,8 @@ describe("Media Center extension", () => { } activeSynthesisRequests += 1; maximumSynthesisRequests = Math.max(maximumSynthesisRequests, activeSynthesisRequests); - await new Promise((resolve) => setTimeout(resolve, 5)); + if (activeSynthesisRequests === 2) synthesisRequestsStarted.resolve(); + await synthesisRequestsStarted.promise; activeSynthesisRequests -= 1; const body = JSON.parse(String(init?.body)); synthesisInputs.push(body.input);