fix: preserve conversation settings for models and scheduled messages

Preserve explicit Runtime model selections and Harness configuration rules without pinning resolved defaults. Expose fixed Runtime models in project settings and keep SSH conversations inheriting their project selection.

Dispatch scheduled instructions through ordinary conversation requests using current history, Runtime, work mode, and knowledge scope. Persist conversation work modes and update scheduling documentation and regression tests.

Validation checkpoint: typecheck, lint, and 104 focused tests passed. The SSH inheritance regression passed in isolation, but its process timed out on exit. Full-suite completion, remaining model documentation review, and real end-to-end validation are still pending.

Release note: Preserve fixed model choices and let scheduled messages use their conversation's current settings and history.
This commit is contained in:
mesalogo
2026-09-10 10:17:45 +08:00
parent fedafa21bd
commit 444902d8ad
22 changed files with 628 additions and 353 deletions
+129 -2
View File
@@ -1684,7 +1684,7 @@ describe("App", () => {
).not.toBeInTheDocument();
expect(
taskChild?.querySelector(".conversation-task-child__meta"),
).toHaveTextContent("Execute · 每周 · 空闲");
).toHaveTextContent("每周 · 空闲");
expect(screen.queryByText("Run")).not.toBeInTheDocument();
fireEvent.click(taskChild!);
const taskRegion = await screen.findByRole("region", {
@@ -1883,7 +1883,7 @@ describe("App", () => {
expect(completedTask).toHaveAccessibleName(/已完成/u);
expect(
completedTask?.querySelector(".conversation-task-child__meta"),
).toHaveTextContent("Ask · 仅一次");
).toHaveTextContent("仅一次");
expect(
completedTask?.querySelector(".conversation-task-child__meta"),
).not.toHaveTextContent("已完成");
@@ -5040,6 +5040,86 @@ describe("App", () => {
expect(runtimeButton).toBeEnabled();
});
it("sends scheduled instructions with the target conversation's current settings and history", async () => {
const conversationId = crypto.randomUUID();
const runId = crypto.randomUUID();
const knowledgeId = crypto.randomUUID();
const historyId = crypto.randomUUID();
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
libraries: [{
id: knowledgeId, name: "Scheduled knowledge", description: "",
storageMode: "managed", graphEnabled: false, graphStrategy: "rules",
sourceCount: 1, documentCount: 1, indexedDocumentCount: 1,
}],
sources: [], documents: [], graphNodes: [], graphRelations: [], evidence: [],
});
vi.mocked(api.conversations.list).mockResolvedValue([{
id: conversationId, projectId, title: "Scheduled target", updatedAt: Date.now(),
runtimeSelection: { provider: "continue" }, workMode: "execute",
knowledgeLibraryIds: [knowledgeId], knowledgeRetrievalMode: "always",
messages: [{ id: historyId, role: "user", content: "Existing context", state: "complete", createdAt: Date.now() }],
}]);
render(<App />);
await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith(conversationId));
act(() => conversationQueueDispatchListener?.({
scheduled: true,
item: { id: runId, scheduleRunId: runId, conversationId, source: "schedule", label: "Timed message", createdAt: new Date().toISOString() },
input: { conversationId, projectId, prompt: "Use existing context" },
}));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
expect(run.mock.calls[0]![0]).toMatchObject({
requestId: runId, queueItemId: runId, conversationId,
runtimeSelection: { provider: "continue" }, workMode: "execute",
knowledgeLibraryIds: [knowledgeId], knowledgeRetrievalMode: "always",
history: [{ role: "user", content: "Existing context" }], historyMessageIds: [historyId],
contextIds: [],
});
expect(api.conversationQueue.enqueueUser).not.toHaveBeenCalled();
expect(await screen.findByText("Use existing context")).toBeInTheDocument();
act(() => {
agentListener?.({ requestId: runId, type: "text", delta: "Latest reply" });
agentListener?.({ requestId: runId, type: "done" });
});
await screen.findByText("Latest reply");
const nextRunId = crypto.randomUUID();
act(() => conversationQueueDispatchListener?.({
scheduled: true,
item: { id: nextRunId, scheduleRunId: nextRunId, conversationId, source: "schedule", label: "Timed message", createdAt: new Date().toISOString() },
input: { conversationId, projectId, prompt: "Continue again" },
}));
await waitFor(() => expect(run).toHaveBeenCalledTimes(2));
expect(run.mock.calls[1]![0]).toMatchObject({
conversationId,
history: [
{ role: "user", content: "Existing context" },
{ role: "user", content: "Use existing context" },
{ role: "assistant", content: "Latest reply" },
],
});
});
it("uses normal project defaults for a new scheduled conversation without another conversation's context", async () => {
const targetId = crypto.randomUUID();
const runId = crypto.randomUUID();
vi.mocked(api.projects.list).mockResolvedValueOnce([{ ...project, defaultWorkMode: "execute", runtimeSelection: { provider: "opencode" } }]);
vi.mocked(api.conversations.list).mockResolvedValue([
{ id: crypto.randomUUID(), projectId, title: "Other conversation", updatedAt: Date.now(), workMode: "ask", runtimeSelection: { provider: "model" }, messages: [{ id: crypto.randomUUID(), role: "user", content: "Do not inherit this source history", state: "complete", createdAt: Date.now() }] },
{ id: targetId, projectId, title: "New scheduled conversation", updatedAt: Date.now(), messages: [] },
]);
render(<App />);
await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith(targetId));
act(() => conversationQueueDispatchListener?.({
scheduled: true,
item: { id: runId, scheduleRunId: runId, conversationId: targetId, source: "schedule", label: "Timed message", createdAt: new Date().toISOString() },
input: { conversationId: targetId, projectId, prompt: "Independent message" },
}));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
expect(run.mock.calls[0]![0]).toMatchObject({
conversationId: targetId, runtimeSelection: { provider: "opencode" }, workMode: "execute",
history: [], knowledgeLibraryIds: [], contextIds: [],
});
});
it("queues another ordinary message while the Conversation is running", async () => {
render(<App />);
@@ -6289,6 +6369,50 @@ describe("App", () => {
).toBeInTheDocument();
});
it("keeps an unselected SSH conversation inheriting the project's fixed model", async () => {
installRemoteProjectsSetting(true);
const remoteProject = {
...project,
id: "00000000-0000-4000-8000-000000000119",
name: "Fixed remote model",
rootPath: "/srv/project",
runtimeSelection: { provider: "opencode" as const, profileId: modelProfileId },
executionSpace: {
kind: "ssh" as const,
hostId: "00000000-0000-4000-8000-000000000219",
remoteRootPath: "/srv/project",
},
builtInDefault: false,
};
const conversationId = "00000000-0000-4000-8000-000000000319";
vi.mocked(api.projects.list).mockResolvedValueOnce([project, remoteProject]);
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
id: conversationId,
projectId: remoteProject.id,
title: "Inherited remote model",
updatedAt: 1_775_000_000_000,
messages: [],
}]);
render(<App />);
await screen.findByRole("button", { name: "当前项目" });
selectProjectOption(remoteProject.name);
await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledWith(
remoteProject.runtimeSelection,
));
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "Verify inherited model" },
});
fireEvent.click(screen.getByRole("button", { name: "发送", exact: true }));
await waitFor(() => {
const saved = vi.mocked(api.conversations.saveLocal).mock.calls
.flatMap(([conversations]) => conversations)
.filter((conversation) => conversation.header.id === conversationId);
expect(saved.length).toBeGreaterThan(0);
expect(saved.every((conversation) => conversation.header.runtimeSelection === undefined)).toBe(true);
});
});
it("keeps managed SSH histories visible while independently blocking recovery projects", async () => {
installRemoteProjectsSetting(true);
const first = {
@@ -8957,6 +9081,9 @@ describe("App", () => {
await screen.findByRole("button", { name: /第二模型/u }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /新建对话/u }));
expect(screen.getByRole("button", { name: /第二模型/u })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "第二模型对话" },
});
+54 -38
View File
@@ -1309,6 +1309,7 @@ function toConversationSnapshots(
id: conversation.id,
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
workMode: conversation.workMode,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
@@ -1352,6 +1353,7 @@ function toLocalConversationHeader(
id: conversation.id,
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
workMode: conversation.workMode,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
@@ -2093,7 +2095,6 @@ function App(): React.JSX.Element {
>(undefined);
const viewRef = useRef<WorkspaceView>("chat");
const heartbeatLoadRequestRef = useRef(0);
const [workMode, setWorkMode] = useState<InteractiveWorkMode>("ask");
const [conversationDrafts, setConversationDrafts] = useState<
Record<string, string>
>({});
@@ -2183,10 +2184,6 @@ function App(): React.JSX.Element {
const toggleAppearanceTheme = useCallback((): void => {
setAppearanceTheme(resolvedAppearanceTheme === "dark" ? "light" : "dark");
}, [resolvedAppearanceTheme]);
const effectiveWorkMode =
workMode === "execute" && runtime?.supportsToolExecution === false
? "ask"
: workMode;
const setExpertMenuOpen = useCallback((open: boolean): void => {
setComposerMenuOpen(open ? "expert" : undefined);
if (open) {
@@ -2707,7 +2704,7 @@ function App(): React.JSX.Element {
);
useEffect(() => {
if (!conversationStoreReady || !projectRecoverySnapshotReady) {
if (!conversationStoreReady || !projectRecoverySnapshotReady || !runtimeSettings) {
return;
}
const conversationIds = [
@@ -2738,7 +2735,7 @@ function App(): React.JSX.Element {
dedupeKey: "conversation-queue-resume",
});
});
}, [conversationStoreReady, projectRecoverySnapshotReady]);
}, [conversationStoreReady, projectRecoverySnapshotReady, conversations.length, runtimeSettings]);
useEffect(() => {
const sweep = (): void => {
@@ -3118,6 +3115,20 @@ function App(): React.JSX.Element {
[activeProjectId, projects],
);
const activeProjectUsesManagedSsh = isManagedSshProject(activeProject);
const workMode = normalizeInteractiveWorkMode(
activeConversation?.workMode ?? activeProject?.defaultWorkMode,
);
const effectiveWorkMode =
workMode === "execute" && runtime?.supportsToolExecution === false
? "ask"
: workMode;
const setWorkMode = (mode: InteractiveWorkMode): void => {
setConversations((current) => current.map((conversation) =>
conversation.id === activeId
? { ...conversation, workMode: mode, updatedAt: Date.now() }
: conversation,
));
};
const activeProjectRecovery =
activeProjectUsesManagedSsh && activeProject
? projectRecoveryByProjectId[activeProject.id]
@@ -5435,7 +5446,6 @@ function App(): React.JSX.Element {
throw new Error("没有可用的本地项目");
}
setActiveProjectId(project.id);
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode));
const persistedLocalConversations = persistedConversations.filter(
(conversation) => !conversation.remote,
);
@@ -6077,7 +6087,6 @@ function App(): React.JSX.Element {
candidateConversations = conversationsRef.current,
): void => {
setActiveProjectId(selected.id);
setWorkMode(normalizeInteractiveWorkMode(selected.defaultWorkMode));
const conversation = candidateConversations.find(
(candidate) =>
candidate.projectId === selected.id &&
@@ -6086,11 +6095,12 @@ function App(): React.JSX.Element {
if (conversation) {
if (isManagedSshProject(selected)) {
const runtimeSelection = runtimeSettings
? getRuntimeSelectionForProvider("opencode", runtimeSettings)
? getProjectDefaultRuntimeSelection(selected, runtimeSettings)
: ({ provider: "opencode" } as const);
setConversations((current) =>
current.map((candidate) =>
candidate.projectId === selected.id &&
candidate.runtimeSelection !== undefined &&
candidate.runtimeSelection?.provider !== "opencode"
? {
...candidate,
@@ -6140,7 +6150,6 @@ function App(): React.JSX.Element {
const project = await window.goodbuddy.projects.create(input);
setProjects((current) => [project, ...current]);
setActiveProjectId(project.id);
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode));
const conversation = createConversation(
project.id,
undefined,
@@ -6162,9 +6171,6 @@ function App(): React.JSX.Element {
candidate.id === project.id ? project : candidate,
),
);
if (project.id === activeProjectId) {
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode));
}
return project;
};
@@ -6766,7 +6772,36 @@ function App(): React.JSX.Element {
const submit = async (
queuedDispatch?: ConversationQueueDispatch,
): Promise<void> => {
const queuedInput = queuedDispatch?.input;
let queuedInput: ConversationQueueUserInput | undefined =
queuedDispatch && !queuedDispatch.scheduled ? queuedDispatch.input : undefined;
if (queuedDispatch?.scheduled) {
const conversation = conversationsRef.current.find(
(candidate) => candidate.id === queuedDispatch.input.conversationId,
);
const project = projectsRef.current.find(
(candidate) => candidate.id === conversation?.projectId,
);
const selection = conversation?.runtimeSelection ??
(runtimeSettings ? getProjectDefaultRuntimeSelection(project, runtimeSettings) : undefined);
if (!conversation || !selection) {
await window.goodbuddy.conversationQueue.releaseUser(queuedDispatch.item.id);
return;
}
const mode = normalizeInteractiveWorkMode(conversation.workMode ?? project?.defaultWorkMode);
queuedInput = {
conversationId: conversation.id,
projectId: conversation.projectId,
prompt: queuedDispatch.input.prompt,
runtimeSelection: selection,
workMode: mode,
includeMemoryContext: true,
attachments: [],
knowledgeLibraryIds: conversation.knowledgeLibraryIds ?? [],
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode ?? "auto",
smartRouting: runtimeSettings?.subagentSmartRoutingEnabled === true &&
supportsSubagentSmartRouting(mode) ? true : undefined,
};
}
const releaseQueuedItem = async (): Promise<void> => {
if (!queuedDispatch) {
return;
@@ -6856,7 +6891,9 @@ function App(): React.JSX.Element {
return;
}
const requestId = crypto.randomUUID();
const requestId = queuedDispatch?.scheduled
? queuedDispatch.item.scheduleRunId!
: crypto.randomUUID();
const conversationId = conversationSnapshot.id;
const attachmentSnapshot = (queuedInput?.attachments ?? attachments).slice(
0,
@@ -6970,7 +7007,7 @@ function App(): React.JSX.Element {
return;
}
if (dispatchedConversationQueueItems.current.has(queuedDispatch.item.id)) {
if (!queuedDispatch || dispatchedConversationQueueItems.current.has(queuedDispatch.item.id)) {
return;
}
dispatchedConversationQueueItems.current.add(queuedDispatch.item.id);
@@ -7655,13 +7692,7 @@ function App(): React.JSX.Element {
return;
}
if (conversation.projectId) {
const project = projects.find(
(candidate) => candidate.id === conversation.projectId,
);
setActiveProjectId(conversation.projectId);
if (project) {
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode));
}
}
setActiveId(conversationId);
setUnreadConversationIds((current) => {
@@ -7694,13 +7725,7 @@ function App(): React.JSX.Element {
return;
}
if (task.projectId) {
const project = projects.find(
(candidate) => candidate.id === task.projectId,
);
setActiveProjectId(task.projectId);
if (project) {
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode));
}
}
setSelectedAssistantTaskId(task.id);
setExpandedTaskConversationIds((current) => {
@@ -7782,13 +7807,7 @@ function App(): React.JSX.Element {
});
}
if (schedule.projectId) {
const project = projects.find(
(candidate) => candidate.id === schedule.projectId,
);
setActiveProjectId(schedule.projectId);
if (project) {
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode));
}
}
setActiveId(schedule.conversationId);
setView("chat");
@@ -8591,7 +8610,6 @@ function App(): React.JSX.Element {
);
const metadata = schedule
? [
tWorkspace(`task.mode.${schedule.workMode}`),
tWorkspace(
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`,
),
@@ -10747,8 +10765,6 @@ function App(): React.JSX.Element {
onCreate={createCustomTask}
projectId={activeProject.id}
projectName={activeProjectDisplayName ?? activeProject.name}
runtimeLabel={activeRuntimeLabel}
supportsToolExecution={Boolean(runtime?.supportsToolExecution)}
workspaceLabel={
activeProject.rootPath || t("customTask.scope.noWorkspace")
}
+6 -16
View File
@@ -39,7 +39,7 @@ describe('CustomTaskDialog', () => {
)
})
it('defaults to Execute and keeps the current Conversation association', async () => {
it('queues a timed message without capturing conversation execution settings', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000801'
const onCreate = vi.fn(async (input) => ({
@@ -61,8 +61,6 @@ describe('CustomTaskDialog', () => {
onCreate={onCreate}
projectId="00000000-0000-4000-8000-000000000804"
projectName="GoodBuddy Desktop"
runtimeLabel="OpenCode"
supportsToolExecution
workspaceLabel="C:\\Workspace"
/>
)
@@ -70,10 +68,7 @@ describe('CustomTaskDialog', () => {
expect(
screen.getByRole('dialog', { name: '新建定制任务' })
).toHaveAttribute('aria-modal', 'true')
expect(screen.getByRole('button', { name: 'Execute' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.queryByRole('button', { name: 'Execute' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '当前会话' })).toHaveAttribute(
'aria-pressed',
'true'
@@ -97,13 +92,14 @@ describe('CustomTaskDialog', () => {
conversationId,
title: '每周项目总结',
prompt: '总结完成和失败的工作',
workMode: 'execute',
recurrence: 'once'
})
)
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('workMode')
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('runtimeSelection')
})
it('uses Ask when the selected Runtime cannot execute tools', () => {
it('creates an independent conversation using normal defaults', () => {
render(
<CustomTaskDialog
currentConversationAvailable={false}
@@ -111,17 +107,11 @@ describe('CustomTaskDialog', () => {
onClose={vi.fn()}
onCreate={vi.fn()}
projectName="GoodBuddy Desktop"
runtimeLabel="Direct model"
supportsToolExecution={false}
workspaceLabel="C:\\Workspace"
/>
)
expect(screen.getByRole('button', { name: 'Execute' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Ask' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.queryByRole('button', { name: 'Ask' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '当前会话' })).toBeDisabled()
})
})
+1 -35
View File
@@ -17,9 +17,7 @@ type CustomTaskDialogProps = {
defaultDestination: CustomTaskDestination
projectId?: string
projectName: string
runtimeLabel: string
workspaceLabel: string
supportsToolExecution: boolean
onClose: () => void
onCreate: (input: ScheduleCreateInput) => Promise<AssistantSchedule>
}
@@ -35,9 +33,7 @@ export function CustomTaskDialog({
defaultDestination,
projectId,
projectName,
runtimeLabel,
workspaceLabel,
supportsToolExecution,
onClose,
onCreate
}: CustomTaskDialogProps): React.JSX.Element {
@@ -51,10 +47,6 @@ export function CustomTaskDialog({
? 'current'
: 'new'
)
const [workMode, setWorkMode] =
useState<ScheduleCreateInput['workMode']>(
supportsToolExecution ? 'execute' : 'ask'
)
const [recurrence, setRecurrence] =
useState<ScheduleCreateInput['recurrence']>('once')
const [nextRunAt, setNextRunAt] = useState(() =>
@@ -119,7 +111,6 @@ export function CustomTaskDialog({
: {}),
title: title.trim(),
prompt: prompt.trim(),
workMode,
recurrence,
nextRunAt: runAt.toISOString()
})
@@ -254,25 +245,6 @@ export function CustomTaskDialog({
</div>
<div className="custom-task-dialog__two-columns">
<div className="custom-task-dialog__choice">
<span>{t('customTask.fields.mode')}</span>
<SegmentedControl
ariaLabel={t('customTask.fields.mode')}
onChange={setWorkMode}
options={[
{
value: 'execute',
label: t('customTask.mode.execute'),
disabled: !supportsToolExecution
},
{ value: 'ask', label: t('customTask.mode.ask') }
]}
value={workMode}
/>
{!supportsToolExecution && (
<small>{t('customTask.mode.executeUnavailable')}</small>
)}
</div>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.recurrence')}</span>
<select
@@ -325,10 +297,6 @@ export function CustomTaskDialog({
<dt>{t('customTask.scope.project')}</dt>
<dd>{projectName}</dd>
</div>
<div>
<dt>{t('customTask.scope.runtime')}</dt>
<dd>{runtimeLabel}</dd>
</div>
<div>
<dt>{t('customTask.scope.workspace')}</dt>
<dd>{workspaceLabel}</dd>
@@ -337,9 +305,7 @@ export function CustomTaskDialog({
<dt>{t('customTask.scope.tools')}</dt>
<dd>
<ShieldCheck aria-hidden="true" size={13} />
{workMode === 'execute'
? t('customTask.scope.executeApproval')
: t('customTask.scope.askReadOnly')}
{t('customTask.scope.conversationSettings')}
</dd>
</div>
</dl>
@@ -0,0 +1,43 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defaultRuntimeSettings, type RuntimeSettings } from '../../shared/contracts'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import i18n from './i18n'
afterEach(cleanup)
describe('ProjectRuntimeSelector rules', () => {
it('allows fixed Runtime models and switching back to a profile-free reference', async () => {
await i18n.changeLanguage('zh-CN')
const profileId = '00000000-0000-4000-8000-000000000011'
const settings = {
...defaultRuntimeSettings,
defaultModelProfileId: profileId,
modelProfiles: [{
id: profileId, name: 'Model A', modelName: 'model-a',
baseUrl: 'https://example.com/v1', protocol: 'openai-chat-completions',
authentication: 'api-key', apiKeyConfigured: true
}]
} as RuntimeSettings
const onChange = vi.fn()
const props = {
ariaLabel: 'Project Runtime', label: 'Runtime', onChange,
runtimeSettings: settings
}
const view = render(<ProjectRuntimeSelector {...props} selection={{ provider: 'opencode' }} />)
const select = screen.getByRole('combobox')
for (const provider of ['opencode', 'continue', 'deepseek-harness'] as const) {
expect(screen.getByRole('option', {
name: `${provider === 'opencode' ? 'OpenCode' : provider === 'continue' ? 'Continue' : 'DeepSeek Harness'} · Model A`
})).toHaveValue(`${provider}:${profileId}`)
fireEvent.change(select, { target: { value: `${provider}:${profileId}` } })
expect(onChange).toHaveBeenLastCalledWith({ provider, profileId })
}
view.rerender(<ProjectRuntimeSelector {...props} selection={{ provider: 'opencode', profileId }} />)
expect(screen.getByText('通过 OpenCode 运行,固定使用 Model A。')).toBeInTheDocument()
fireEvent.change(select, { target: { value: 'opencode:default' } })
expect(onChange).toHaveBeenLastCalledWith({ provider: 'opencode' })
fireEvent.change(select, { target: { value: 'model:default' } })
expect(onChange).toHaveBeenLastCalledWith({ provider: 'model' })
})
})
+41 -2
View File
@@ -1,6 +1,10 @@
import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next'
import type { RuntimeSettings } from '../../shared/contracts'
import {
isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile,
type RuntimeSettings
} from '../../shared/contracts'
import {
agentRuntimeSelectionKey,
getRuntimeSelectionForProvider,
@@ -54,6 +58,15 @@ function runtimeSelectionDescription(
: selection.provider === 'continue'
? 'Continue'
: 'DeepSeek Harness'
if (selection.profileId) {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId
)
return t('channels.project.fixedRuntimeDescription', {
runtime: runtimeLabel,
name: profile?.name ?? t('channels.project.missingProfile')
})
}
return t('channels.project.runtimeDescription', {
runtime: runtimeLabel
})
@@ -108,9 +121,22 @@ export function ProjectRuntimeSelector({
...(selectionMode === 'configured' ? [{ provider: 'auto' as const }] : []),
{ provider: 'model' }
]
const fixedRuntimeSelections = selectionMode === 'configured'
? runtimeProviders.flatMap((provider) =>
directProfiles.filter((profile) =>
provider === 'deepseek-harness'
? isDeepSeekHarnessModelProfile(profile)
: isAgentRuntimeModelProtocol(profile.protocol)
).map((profile) => ({ provider, profileId: profile.id }))
)
: []
const fixedRuntimeSelection = runtimeSelection.provider !== 'model' &&
'profileId' in runtimeSelection && runtimeSelection.profileId ? runtimeSelection : undefined
'profileId' in runtimeSelection && runtimeSelection.profileId &&
!fixedRuntimeSelections.some((candidate) =>
agentRuntimeSelectionKey(candidate) === agentRuntimeSelectionKey(runtimeSelection)
) ? runtimeSelection : undefined
const selections = [...inheritedSelections, ...directSelections, ...runtimeSelections,
...fixedRuntimeSelections,
...(fixedRuntimeSelection ? [fixedRuntimeSelection] : [])]
const selectionByKey = new Map(
selections.map((candidate) => [
@@ -190,6 +216,19 @@ export function ProjectRuntimeSelector({
</option>
))}
</optgroup>
{fixedRuntimeSelections.length > 0 && (
<optgroup label={t('channels.project.fixedRuntimeModels')}>
{fixedRuntimeSelections.map((candidate) => (
<option
key={agentRuntimeSelectionKey(candidate)}
value={agentRuntimeSelectionKey(candidate)}
>
{candidate.provider === 'opencode' ? 'OpenCode' : candidate.provider === 'continue' ? 'Continue' : 'DeepSeek Harness'}
{' · '}{runtimeSettings.modelProfiles.find((profile) => profile.id === candidate.profileId)?.name}
</option>
))}
</optgroup>
)}
</select>
<small>
{runtimeSelectionDescription(
+4 -3
View File
@@ -131,7 +131,7 @@ export const app = {
eyebrow: 'CUSTOM TASK',
title: 'New custom task',
description:
'Create a task that runs on schedule and keeps recording results in a conversation.',
'Send task instructions to an ordinary conversation on schedule. Replies stay in the conversation just like manually sent messages.',
close: 'Close new custom task',
fields: {
name: 'Task name',
@@ -145,9 +145,9 @@ export const app = {
current: 'Current conversation',
new: 'New conversation',
currentHelp:
'Associate the task with this conversation without renaming it or changing normal chat.',
'Use this conversation\'s history, Runtime, work mode, and enabled capabilities when the message is sent.',
newHelp:
'Create a conversation for this task, using the task name as its initial title.',
'Create an independent ordinary conversation with project defaults and the task name as its title.',
currentUnavailable:
'The current conversation cannot host this task. Choose a new conversation.'
},
@@ -163,6 +163,7 @@ export const app = {
weekly: 'Weekly'
},
scope: {
conversationSettings: 'Use the target conversation\'s Runtime, work mode, and enabled tools at send time. Adjust them in the conversation.',
title: 'Execution scope',
project: 'Project',
runtime: 'Runtime',
@@ -46,6 +46,8 @@ export const integrations = {
directDescription:
'Process messages directly with {{name}} ({{modelName}}).',
defaultDirect: 'Direct · Follow the global default model',
fixedRuntimeModels: 'Agent Runtime · Fixed model',
fixedRuntimeDescription: 'Run through {{runtime}} with the fixed model {{name}}.',
automaticDescription:
'Follow the global default Runtime setting.',
runtimeDescription:
+4 -3
View File
@@ -123,7 +123,7 @@ export const app = {
customTask: {
eyebrow: '定制任务',
title: '新建定制任务',
description: '创建一个按计划自动运行,并持续记录在会话中的任务。',
description: '按计划向普通会话发送任务要求,回复和手动发送的消息一样保留在会话中。',
close: '关闭新建定制任务',
fields: {
name: '任务名称',
@@ -136,8 +136,8 @@ export const app = {
destination: {
current: '当前会话',
new: '新建会话',
currentHelp: '把任务关联到当前会话,不更改会话名称或普通聊天能力。',
newHelp: '为任务创建一条新会话,默认使用任务名称作为标题。',
currentHelp: '发送时使用当前会话的历史、Runtime、工作模式和已启用能力。',
newHelp: '创建独立的普通会话,使用项目默认设置,以任务名称作为标题。',
currentUnavailable: '当前会话不能关联此任务,请选择新建会话。'
},
mode: {
@@ -151,6 +151,7 @@ export const app = {
weekly: '每周'
},
scope: {
conversationSettings: '发送时使用目标会话的 Runtime、工作模式和已启用工具,可在会话中调整。',
title: '执行范围',
project: '项目',
runtime: 'Runtime',
@@ -40,6 +40,8 @@ export const integrations = {
'直接使用 {{name}}({{modelName}})处理消息。',
automaticDescription: '跟随全局 Runtime 默认设置',
defaultDirect: '直连 · 跟随全局默认模型',
fixedRuntimeModels: 'Agent Runtime · 固定模型',
fixedRuntimeDescription: '通过 {{runtime}} 运行,固定使用 {{name}}。',
runtimeDescription:
'通过 {{runtime}} Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 {{runtime}} 配置。',
defaultMode: '默认模式',
@@ -3,6 +3,7 @@ import type { RuntimeSettings } from '../../shared/contracts'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionProfileId,
repairChannelRuntimeSelection,
getRuntimeSelectionForProvider
} from '../../shared/runtime-selection-contracts'
@@ -18,6 +19,26 @@ function harnessSettings(
}
describe('DeepSeek Harness runtime selection', () => {
it.each(['opencode', 'continue', 'deepseek-harness'] as const)(
'preserves an explicit %s channel profile even when it equals the old default', (provider) => {
const selection = { provider, profileId: harnessProfileId }
const settings = {
defaultModelProfileId: '00000000-0000-4000-8000-000000000072',
modelProfiles: [{
id: harnessProfileId,
baseUrl: 'https://gateway.example/v1',
protocol: 'openai-chat-completions',
authentication: 'api-key' as const,
apiKeyConfigured: true
}],
opencodeModelSource: { kind: 'default' as const },
continueModelSource: { kind: 'platform' as const }
}
expect(repairChannelRuntimeSelection(selection, settings)).toEqual(selection)
expect(repairChannelRuntimeSelection({ provider }, settings)).toEqual({ provider })
}
)
it('references the Runtime configuration without copying its profile', () => {
const selection = getRuntimeSelectionForProvider(
'deepseek-harness',