Allow ACP providers without model lists
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
import {
|
import {
|
||||||
buildModelRows,
|
buildModelRows,
|
||||||
|
buildProviderGroups,
|
||||||
buildSelectedTriggerLabel,
|
buildSelectedTriggerLabel,
|
||||||
filterAndRankModelRows,
|
filterAndRankModelRows,
|
||||||
matchesSearch,
|
matchesSearch,
|
||||||
@@ -24,6 +25,13 @@ describe("combined model selector helpers", () => {
|
|||||||
defaultModeId: "auto",
|
defaultModeId: "auto",
|
||||||
modes: [],
|
modes: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "deepseek-tui",
|
||||||
|
label: "DeepSeek TUI",
|
||||||
|
description: "DeepSeek TUI provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const claudeModels: AgentModelDefinition[] = [
|
const claudeModels: AgentModelDefinition[] = [
|
||||||
@@ -112,6 +120,53 @@ describe("combined model selector helpers", () => {
|
|||||||
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
|
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes providers that expose no models", () => {
|
||||||
|
const rows = buildModelRows(
|
||||||
|
providerDefinitions,
|
||||||
|
new Map([
|
||||||
|
["claude", claudeModels],
|
||||||
|
["deepseek-tui", []],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = buildProviderGroups(
|
||||||
|
providerDefinitions,
|
||||||
|
new Map([
|
||||||
|
["claude", claudeModels],
|
||||||
|
["deepseek-tui", []],
|
||||||
|
]),
|
||||||
|
rows,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(groups).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
providerId: "claude",
|
||||||
|
hasNoModels: false,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
providerId: "deepseek-tui",
|
||||||
|
providerLabel: "DeepSeek TUI",
|
||||||
|
rows: [],
|
||||||
|
hasNoModels: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches model-less providers by provider name", () => {
|
||||||
|
const groups = buildProviderGroups(
|
||||||
|
providerDefinitions,
|
||||||
|
new Map([
|
||||||
|
["claude", claudeModels],
|
||||||
|
["deepseek-tui", []],
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
"deepseek",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(groups.map((group) => group.providerId)).toEqual(["deepseek-tui"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the selected trigger label model-only", () => {
|
it("keeps the selected trigger label model-only", () => {
|
||||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ function drillDownRowStyle({
|
|||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import {
|
import {
|
||||||
buildModelRows,
|
buildModelRows,
|
||||||
|
buildProviderGroups,
|
||||||
buildSelectedTriggerLabel,
|
buildSelectedTriggerLabel,
|
||||||
filterAndRankModelRows,
|
filterAndRankModelRows,
|
||||||
resolveProviderLabel,
|
resolveProviderLabel,
|
||||||
|
type SelectorProviderGroup,
|
||||||
type SelectorModelRow,
|
type SelectorModelRow,
|
||||||
} from "./combined-model-selector.utils";
|
} from "./combined-model-selector.utils";
|
||||||
|
|
||||||
@@ -122,31 +124,6 @@ function sortFavoritesFirst(
|
|||||||
return [...favorites, ...rest];
|
return [...favorites, ...rest];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupRowsByProvider(
|
|
||||||
rows: SelectorModelRow[],
|
|
||||||
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
|
||||||
const grouped = new Map<
|
|
||||||
string,
|
|
||||||
{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const existing = grouped.get(row.provider);
|
|
||||||
if (existing) {
|
|
||||||
existing.rows.push(row);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
grouped.set(row.provider, {
|
|
||||||
providerId: row.provider,
|
|
||||||
providerLabel: row.providerLabel,
|
|
||||||
rows: [row],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(grouped.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelRow({
|
function ModelRow({
|
||||||
row,
|
row,
|
||||||
isSelected,
|
isSelected,
|
||||||
@@ -317,29 +294,41 @@ interface GroupProviderButtonProps {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
providerLabel: string;
|
providerLabel: string;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
|
hasNoModels: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
|
onSelectDefault: (providerId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupProviderButton({
|
function GroupProviderButton({
|
||||||
providerId,
|
providerId,
|
||||||
providerLabel,
|
providerLabel,
|
||||||
rowCount,
|
rowCount,
|
||||||
|
hasNoModels,
|
||||||
|
disabled,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
|
onSelectDefault,
|
||||||
}: GroupProviderButtonProps) {
|
}: GroupProviderButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const ProvIcon = getProviderIcon(providerId);
|
const ProvIcon = getProviderIcon(providerId);
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
|
if (hasNoModels) {
|
||||||
|
onSelectDefault(providerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onDrillDown(providerId, providerLabel);
|
onDrillDown(providerId, providerLabel);
|
||||||
}, [onDrillDown, providerId, providerLabel]);
|
}, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
<Pressable disabled={disabled} onPress={handlePress} style={drillDownRowStyle}>
|
||||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
<Text style={styles.drillDownCount}>
|
<Text style={styles.drillDownCount}>
|
||||||
{rowCount} {rowCount === 1 ? "model" : "models"}
|
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
{hasNoModels ? null : (
|
||||||
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -348,9 +337,13 @@ function GroupProviderButton({
|
|||||||
function GroupedProviderRows({
|
function GroupedProviderRows({
|
||||||
groupedRows,
|
groupedRows,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
|
onSelectDefault,
|
||||||
|
canSelectProvider,
|
||||||
}: {
|
}: {
|
||||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
groupedRows: SelectorProviderGroup[];
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
|
onSelectDefault: (providerId: string) => void;
|
||||||
|
canSelectProvider: (provider: string) => boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -362,7 +355,10 @@ function GroupedProviderRows({
|
|||||||
providerId={group.providerId}
|
providerId={group.providerId}
|
||||||
providerLabel={group.providerLabel}
|
providerLabel={group.providerLabel}
|
||||||
rowCount={group.rows.length}
|
rowCount={group.rows.length}
|
||||||
|
hasNoModels={group.hasNoModels}
|
||||||
|
disabled={group.hasNoModels && !canSelectProvider(group.providerId)}
|
||||||
onDrillDown={onDrillDown}
|
onDrillDown={onDrillDown}
|
||||||
|
onSelectDefault={onSelectDefault}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -371,6 +367,38 @@ function GroupedProviderRows({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DefaultProviderRow({
|
||||||
|
providerId,
|
||||||
|
isSelected,
|
||||||
|
disabled,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
providerId: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const ProviderIcon = getProviderIcon(providerId);
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onSelect(providerId, "");
|
||||||
|
}, [onSelect, providerId]);
|
||||||
|
const leadingSlot = useMemo(
|
||||||
|
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
|
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
label="Default"
|
||||||
|
selected={isSelected}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={handlePress}
|
||||||
|
leadingSlot={leadingSlot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProviderModelRows({
|
function ProviderModelRows({
|
||||||
rows,
|
rows,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
@@ -472,7 +500,16 @@ function SelectorContent({
|
|||||||
[favoriteKeys, visibleRows],
|
[favoriteKeys, visibleRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
|
const allGroupedRows = useMemo(
|
||||||
|
() => buildProviderGroups(providerDefinitions, allProviderModels, visibleRows, normalizedQuery),
|
||||||
|
[allProviderModels, normalizedQuery, providerDefinitions, visibleRows],
|
||||||
|
);
|
||||||
|
const handleSelectDefaultProvider = useCallback(
|
||||||
|
(providerId: string) => {
|
||||||
|
onSelect(providerId, "");
|
||||||
|
},
|
||||||
|
[onSelect],
|
||||||
|
);
|
||||||
const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
|
const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
|
||||||
const emptyState = (
|
const emptyState = (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
@@ -482,6 +519,18 @@ function SelectorContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (view.kind === "provider") {
|
if (view.kind === "provider") {
|
||||||
|
const providerModels = allProviderModels.get(view.providerId);
|
||||||
|
if (providerModels && providerModels.length === 0 && !normalizedQuery) {
|
||||||
|
return (
|
||||||
|
<DefaultProviderRow
|
||||||
|
providerId={view.providerId}
|
||||||
|
isSelected={view.providerId === selectedProvider && !selectedModel}
|
||||||
|
disabled={!canSelectProvider(view.providerId)}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (visibleRows.length === 0) {
|
if (visibleRows.length === 0) {
|
||||||
return emptyState;
|
return emptyState;
|
||||||
}
|
}
|
||||||
@@ -513,7 +562,12 @@ function SelectorContent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{allGroupedRows.length > 0 ? (
|
{allGroupedRows.length > 0 ? (
|
||||||
<GroupedProviderRows groupedRows={allGroupedRows} onDrillDown={onDrillDown} />
|
<GroupedProviderRows
|
||||||
|
groupedRows={allGroupedRows}
|
||||||
|
onDrillDown={onDrillDown}
|
||||||
|
onSelectDefault={handleSelectDefaultProvider}
|
||||||
|
canSelectProvider={canSelectProvider}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!hasResults ? emptyState : null}
|
{!hasResults ? emptyState : null}
|
||||||
@@ -598,6 +652,10 @@ export function CombinedModelSelector({
|
|||||||
if (!hasSelectedProvider) {
|
if (!hasSelectedProvider) {
|
||||||
return "Select model";
|
return "Select model";
|
||||||
}
|
}
|
||||||
|
const models = allProviderModels.get(selectedProvider);
|
||||||
|
if (models && models.length === 0) {
|
||||||
|
return "Default";
|
||||||
|
}
|
||||||
return isLoading ? "Loading..." : "Select model";
|
return isLoading ? "Loading..." : "Select model";
|
||||||
}
|
}
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
|||||||
|
|
||||||
export type SelectorModelRow = FavoriteModelRow;
|
export type SelectorModelRow = FavoriteModelRow;
|
||||||
|
|
||||||
|
export interface SelectorProviderGroup {
|
||||||
|
providerId: string;
|
||||||
|
providerLabel: string;
|
||||||
|
rows: SelectorModelRow[];
|
||||||
|
hasNoModels: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveProviderLabel(
|
export function resolveProviderLabel(
|
||||||
providerDefinitions: AgentProviderDefinition[],
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
providerId: string,
|
providerId: string,
|
||||||
@@ -75,3 +82,55 @@ export function filterAndRankModelRows(
|
|||||||
|
|
||||||
return scored.map((entry) => entry.row);
|
return scored.map((entry) => entry.row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildProviderGroups(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||||
|
rows: SelectorModelRow[],
|
||||||
|
normalizedQuery: string,
|
||||||
|
): SelectorProviderGroup[] {
|
||||||
|
const rowsByProvider = new Map<string, SelectorModelRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const providerRows = rowsByProvider.get(row.provider);
|
||||||
|
if (providerRows) {
|
||||||
|
providerRows.push(row);
|
||||||
|
} else {
|
||||||
|
rowsByProvider.set(row.provider, [row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: SelectorProviderGroup[] = [];
|
||||||
|
for (const definition of providerDefinitions) {
|
||||||
|
const providerRows = rowsByProvider.get(definition.id) ?? [];
|
||||||
|
if (providerRows.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
providerId: definition.id,
|
||||||
|
providerLabel: definition.label,
|
||||||
|
rows: providerRows,
|
||||||
|
hasNoModels: false,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = allProviderModels.get(definition.id);
|
||||||
|
if (!models || models.length > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerMatches =
|
||||||
|
!normalizedQuery ||
|
||||||
|
scoreTextFields(normalizedQuery, [definition.label, definition.id]) !== null;
|
||||||
|
if (!providerMatches) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
providerId: definition.id,
|
||||||
|
providerLabel: definition.label,
|
||||||
|
rows: [],
|
||||||
|
hasNoModels: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface WorkspaceDraftAutoSubmitConfig {
|
||||||
|
provider: string;
|
||||||
|
model: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDraftSubmission(input: {
|
||||||
|
text: string;
|
||||||
|
allowsEmptyAutoSubmit: boolean;
|
||||||
|
composerState: {
|
||||||
|
providerDefinitions: unknown[];
|
||||||
|
selectedProvider: string | null;
|
||||||
|
isModelLoading: boolean;
|
||||||
|
effectiveModelId: string | null;
|
||||||
|
availableModels: unknown[];
|
||||||
|
};
|
||||||
|
autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null;
|
||||||
|
workspaceDirectory: string | null;
|
||||||
|
hasClient: boolean;
|
||||||
|
}): string | null {
|
||||||
|
const {
|
||||||
|
text,
|
||||||
|
allowsEmptyAutoSubmit,
|
||||||
|
composerState,
|
||||||
|
autoSubmitConfig,
|
||||||
|
workspaceDirectory,
|
||||||
|
hasClient,
|
||||||
|
} = input;
|
||||||
|
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
||||||
|
return "Initial prompt is required";
|
||||||
|
}
|
||||||
|
if (composerState.providerDefinitions.length === 0) {
|
||||||
|
return "No available providers on the selected host";
|
||||||
|
}
|
||||||
|
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
||||||
|
return "Select a model";
|
||||||
|
}
|
||||||
|
if (composerState.isModelLoading) {
|
||||||
|
return "Model defaults are still loading";
|
||||||
|
}
|
||||||
|
const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId);
|
||||||
|
if (!hasSelectedModel && composerState.availableModels.length > 0) {
|
||||||
|
return "No model is available for the selected provider";
|
||||||
|
}
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
return "Workspace directory not found";
|
||||||
|
}
|
||||||
|
if (!hasClient) {
|
||||||
|
return "Host is not connected";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { validateDraftSubmission } from "./workspace-draft-agent-tab-core";
|
||||||
|
|
||||||
|
const baseComposerState = {
|
||||||
|
providerDefinitions: [{ id: "deepseek-tui" }],
|
||||||
|
selectedProvider: "deepseek-tui",
|
||||||
|
isModelLoading: false,
|
||||||
|
effectiveModelId: "",
|
||||||
|
availableModels: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function validate(overrides = {}) {
|
||||||
|
return validateDraftSubmission({
|
||||||
|
text: "hello",
|
||||||
|
allowsEmptyAutoSubmit: false,
|
||||||
|
composerState: baseComposerState,
|
||||||
|
autoSubmitConfig: null,
|
||||||
|
workspaceDirectory: "/tmp/project",
|
||||||
|
hasClient: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workspace draft agent model validation", () => {
|
||||||
|
test("allows a ready provider with no models to submit without a selected model", () => {
|
||||||
|
expect(validate({})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps waiting while model defaults are loading", () => {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
composerState: {
|
||||||
|
...baseComposerState,
|
||||||
|
isModelLoading: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("Model defaults are still loading");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still requires a selected model when the provider exposes models", () => {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
composerState: {
|
||||||
|
...baseComposerState,
|
||||||
|
availableModels: [{ id: "deepseek/deepseek-v4-pro" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("No model is available for the selected provider");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
|
|||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
|
import { validateDraftSubmission } from "@/screens/workspace/workspace-draft-agent-tab-core";
|
||||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
@@ -73,51 +74,6 @@ function resolveAutoSubmitConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateDraftSubmission(input: {
|
|
||||||
text: string;
|
|
||||||
allowsEmptyAutoSubmit: boolean;
|
|
||||||
composerState: {
|
|
||||||
providerDefinitions: unknown[];
|
|
||||||
selectedProvider: string | null;
|
|
||||||
isModelLoading: boolean;
|
|
||||||
effectiveModelId: string | null;
|
|
||||||
};
|
|
||||||
autoSubmitConfig: AutoSubmitConfig | null;
|
|
||||||
workspaceDirectory: string | null;
|
|
||||||
hasClient: boolean;
|
|
||||||
}): string | null {
|
|
||||||
const {
|
|
||||||
text,
|
|
||||||
allowsEmptyAutoSubmit,
|
|
||||||
composerState,
|
|
||||||
autoSubmitConfig,
|
|
||||||
workspaceDirectory,
|
|
||||||
hasClient,
|
|
||||||
} = input;
|
|
||||||
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
|
||||||
return "Initial prompt is required";
|
|
||||||
}
|
|
||||||
if (composerState.providerDefinitions.length === 0) {
|
|
||||||
return "No available providers on the selected host";
|
|
||||||
}
|
|
||||||
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
|
||||||
return "Select a model";
|
|
||||||
}
|
|
||||||
if (composerState.isModelLoading) {
|
|
||||||
return "Model defaults are still loading";
|
|
||||||
}
|
|
||||||
if (!(autoSubmitConfig?.model ?? composerState.effectiveModelId)) {
|
|
||||||
return "No model is available for the selected provider";
|
|
||||||
}
|
|
||||||
if (!workspaceDirectory) {
|
|
||||||
return "Workspace directory not found";
|
|
||||||
}
|
|
||||||
if (!hasClient) {
|
|
||||||
return "Host is not connected";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDraftModeIdOverride(input: {
|
function resolveDraftModeIdOverride(input: {
|
||||||
autoSubmitConfig: AutoSubmitConfig | null;
|
autoSubmitConfig: AutoSubmitConfig | null;
|
||||||
modeOptionsCount: number;
|
modeOptionsCount: number;
|
||||||
|
|||||||
@@ -731,6 +731,31 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not fail session start when configured model cannot be applied by ACP", async () => {
|
||||||
|
const logger = createTestLogger();
|
||||||
|
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
||||||
|
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||||
|
const session = createSessionWithConfig(
|
||||||
|
{ provider: "deepseek-tui", model: "deepseek/v4" },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
const { internals, setSessionConfigOption, unstableSetSessionModel } =
|
||||||
|
prepareConfiguredOverrideSession(session, {
|
||||||
|
currentModel: null,
|
||||||
|
availableModels: null,
|
||||||
|
configOptions: [],
|
||||||
|
connection: { unstable_setSessionModel: undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(internals.applyConfiguredOverrides()).resolves.toBeUndefined();
|
||||||
|
expect(unstableSetSessionModel).not.toHaveBeenCalled();
|
||||||
|
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
||||||
|
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||||
|
{ value: "deepseek/v4" },
|
||||||
|
"deepseek-tui does not expose ACP model selection; using provider default model",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
|
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const internals = asInternals<ACPSessionInternals>(session);
|
const internals = asInternals<ACPSessionInternals>(session);
|
||||||
|
|||||||
@@ -1376,7 +1376,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof this.connection.unstable_setSessionModel !== "function") {
|
if (typeof this.connection.unstable_setSessionModel !== "function") {
|
||||||
throw new Error(`${this.provider} does not expose ACP model selection`);
|
throw new Error(this.modelSelectionUnavailableMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1398,7 +1398,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
|
|
||||||
const modelOption = selection.configOption;
|
const modelOption = selection.configOption;
|
||||||
if (!modelOption) {
|
if (!modelOption) {
|
||||||
throw new Error(`${this.provider} does not expose ACP model selection`);
|
throw new Error(this.modelSelectionUnavailableMessage());
|
||||||
}
|
}
|
||||||
if (!selection.configChoice) {
|
if (!selection.configChoice) {
|
||||||
this.warnInvalidSelection(
|
this.warnInvalidSelection(
|
||||||
@@ -1904,7 +1904,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
availableModels: this.availableModels,
|
availableModels: this.availableModels,
|
||||||
configOptions: this.configOptions,
|
configOptions: this.configOptions,
|
||||||
});
|
});
|
||||||
await this.setModelWithSelection({ modelId: configuredModelId, selection });
|
try {
|
||||||
|
await this.setModelWithSelection({ modelId: configuredModelId, selection });
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isModelSelectionUnavailableError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.warn(
|
||||||
|
{ value: configuredModelId },
|
||||||
|
`${this.provider} does not expose ACP model selection; using provider default model`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
||||||
await this.setThinkingOption(this.config.thinkingOptionId);
|
await this.setThinkingOption(this.config.thinkingOptionId);
|
||||||
@@ -1915,6 +1925,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
this.logger.warn({ value }, message);
|
this.logger.warn({ value }, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private modelSelectionUnavailableMessage(): string {
|
||||||
|
return `${this.provider} does not expose ACP model selection`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isModelSelectionUnavailableError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.message === this.modelSelectionUnavailableMessage();
|
||||||
|
}
|
||||||
|
|
||||||
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
|
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
|
||||||
switch (update.sessionUpdate) {
|
switch (update.sessionUpdate) {
|
||||||
case "user_message_chunk": {
|
case "user_message_chunk": {
|
||||||
|
|||||||
Reference in New Issue
Block a user