Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
665 changes: 665 additions & 0 deletions src-tauri/src/commands/acp.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1707,6 +1707,8 @@ mod tauri_app {
acp_commands::acp_update_agent_config,
acp_commands::acp_update_hermes_config,
acp_commands::acp_update_kimi_code_config,
acp_commands::acp_hermes_model_options,
acp_commands::acp_set_hermes_model,
acp_commands::acp_fetch_kimi_models,
deepseek_settings_commands::acp_load_deepseek_model_catalog,
deepseek_settings_commands::acp_update_deepseek_model_catalog,
Expand Down
41 changes: 41 additions & 0 deletions src-tauri/src/web/handlers/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,47 @@ pub async fn acp_update_kimi_code_config(
Ok(Json(affected))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpHermesModelOptionsParams {
pub agent_type: AgentType,
}

pub async fn acp_hermes_model_options(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<AcpHermesModelOptionsParams>,
) -> Result<Json<Option<acp_commands::HermesModelOptions>>, AppCommandError> {
let options = acp_commands::acp_hermes_model_options_core(params.agent_type, &state.db)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
Ok(Json(options))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpSetHermesModelParams {
pub agent_type: AgentType,
pub model: String,
}

pub async fn acp_set_hermes_model(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<AcpSetHermesModelParams>,
) -> Result<Json<usize>, AppCommandError> {
let emitter = state.emitter.clone();
let affected = acp_commands::acp_set_hermes_model_and_refresh(
params.agent_type,
&params.model,
&state.db,
&state.connection_manager,
&state.data_dir,
&emitter,
)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
Ok(Json(affected))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpFetchKimiModelsParams {
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/web/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,14 @@ pub fn build_router(
"/acp_update_kimi_code_config",
post(handlers::acp::acp_update_kimi_code_config),
)
.route(
"/acp_hermes_model_options",
post(handlers::acp::acp_hermes_model_options),
)
.route(
"/acp_set_hermes_model",
post(handlers::acp::acp_set_hermes_model),
)
.route(
"/acp_fetch_kimi_models",
post(handlers::acp::acp_fetch_kimi_models),
Expand Down
35 changes: 33 additions & 2 deletions src/components/canvas/canvas-conversation-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { MessageListView } from "@/components/message/message-list-view"
import { useAcpActions } from "@/contexts/acp-connections-context"
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
import { useConversationDetail } from "@/hooks/use-conversation-detail"
import {
HERMES_MODEL_CONFIG_ID,
useHermesModelOption,
} from "@/hooks/use-hermes-model-option"
import {
acpStopAsyncTask,
createChatConversation,
Expand Down Expand Up @@ -701,10 +705,37 @@ export function CanvasConversationSurface({
() => conn.modes?.available_modes ?? [],
[conn.modes]
)
const connectionConfigOptions = useMemo(
const acpConfigOptions = useMemo(
() => conn.configOptions ?? [],
[conn.configOptions]
)
// Same synthetic Hermes model option as the detail panel — the canvas surface
// hosts the same composer, so it must offer the same picker.
const { option: hermesModelOption, selectModel: selectHermesModel } =
useHermesModelOption({
agentType,
configOptions: acpConfigOptions,
status: conn.status,
reapplyConfig: conn.reapplyConfig,
canReconnect: !conn.isViewer && !conn.isDelegationChild,
})
const connectionConfigOptions = useMemo(
() =>
hermesModelOption
? [...acpConfigOptions, hermesModelOption]
: acpConfigOptions,
[acpConfigOptions, hermesModelOption]
)
const handleConfigOptionChange = useCallback(
(configId: string, valueId: string) => {
if (configId === HERMES_MODEL_CONFIG_ID) {
selectHermesModel(valueId)
return
}
handleSetConfigOption(configId, valueId)
},
[handleSetConfigOption, selectHermesModel]
)
const selectedModeId = useMemo(() => {
if (connectionModes.length === 0) return null
if (modeId && connectionModes.some((mode) => mode.id === modeId)) {
Expand Down Expand Up @@ -761,7 +792,7 @@ export function CanvasConversationSurface({
selectorsLoading={selectorsLoading}
selectedModeId={selectedModeId}
onModeChange={handleModeChange}
onConfigOptionChange={handleSetConfigOption}
onConfigOptionChange={handleConfigOptionChange}
agentType={agentType}
availableCommands={conn.availableCommands ?? []}
draftStorageKey={`canvas-draft:${contextKey}`}
Expand Down
51 changes: 51 additions & 0 deletions src/components/chat/hermes-model-option-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { describe, expect, it } from "vitest"

import { HERMES_MODEL_CONFIG_ID } from "@/hooks/use-hermes-model-option"

// Every surface that mounts the composer. A new one that forgets the routing
// would send codeg's synthetic id to the agent; a new one that forgets the
// option would silently drop the picker on that surface.
const SURFACES = [
"src/components/conversations/conversation-detail-panel.tsx",
"src/components/canvas/canvas-conversation-surface.tsx",
] as const

const sources = SURFACES.map(
(path) => [path, readFileSync(resolve(process.cwd(), path), "utf8")] as const
)

describe("Hermes model option wiring", () => {
/**
* WHY: `configOptions` is the list the composer hands straight to the
* backend's `session/set_config_option` on a pick. codeg's synthetic Hermes
* model option is not one of those — Hermes never advertised it — so every
* surface that offers the option MUST also intercept its id. A surface that
* merges the option but routes it like an ACP one fails at the moment the
* user picks a model: the agent rejects an id it does not know, and the model
* silently stays where it was.
*/
it.each(sources)(
"%s intercepts the synthetic id instead of forwarding it to the agent",
(_path, source) => {
expect(source).toContain("useHermesModelOption")
expect(source).toContain("hermesModelOption")
expect(source).toContain(`if (configId === HERMES_MODEL_CONFIG_ID)`)
// The composer must receive the intercepting handler, never the raw ACP one.
expect(source).toContain(
"onConfigOptionChange={handleConfigOptionChange}"
)
expect(source).not.toContain(
"onConfigOptionChange={handleSetConfigOption}"
)
}
)

// The prefix is the whole reason the interception is recognisable. Renaming
// the id without keeping it namespaced would make it indistinguishable from
// an agent-advertised option.
it("keeps the synthetic id namespaced to codeg", () => {
expect(HERMES_MODEL_CONFIG_ID).toBe("codeg:hermes-model")
})
})
40 changes: 37 additions & 3 deletions src/components/conversations/conversation-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ import {
} from "@/stores/conversation-runtime-store"
import { useShallow } from "zustand/react/shallow"
import { useConversationDetail } from "@/hooks/use-conversation-detail"
import {
HERMES_MODEL_CONFIG_ID,
useHermesModelOption,
} from "@/hooks/use-hermes-model-option"
import {
buildSteerPayload,
extractUserImagesFromDraft,
Expand Down Expand Up @@ -705,10 +709,40 @@ const ConversationTabView = memo(function ConversationTabView({
() => effectiveModes?.available_modes ?? [],
[effectiveModes]
)
const connectionConfigOptions = useMemo(
const acpConfigOptions = useMemo(
() => effectiveConfigOptions ?? [],
[effectiveConfigOptions]
)
// Hermes publishes no model selector over ACP, so codeg synthesizes one from
// the profile's own config.yaml (see `useHermesModelOption`). Null for every
// other agent, and for a Hermes build that ever starts advertising its own.
const { option: hermesModelOption, selectModel: selectHermesModel } =
useHermesModelOption({
agentType: selectedAgent,
configOptions: acpConfigOptions,
status: connStatus,
reapplyConfig: conn.reapplyConfig,
canReconnect: !conn.isViewer && !conn.isDelegationChild,
})
const connectionConfigOptions = useMemo(
() =>
hermesModelOption
? [...acpConfigOptions, hermesModelOption]
: acpConfigOptions,
[acpConfigOptions, hermesModelOption]
)
// The synthetic option is codeg's, not the agent's: routing it to the ACP
// `session/set_config_option` would hand Hermes an id it never advertised.
const handleConfigOptionChange = useCallback(
(configId: string, valueId: string) => {
if (configId === HERMES_MODEL_CONFIG_ID) {
selectHermesModel(valueId)
return
}
handleSetConfigOption(configId, valueId)
},
[handleSetConfigOption, selectHermesModel]
)
const connectionCommands = useMemo(
() => (connIsForOtherAgent ? [] : (conn.availableCommands ?? [])),
[connIsForOtherAgent, conn.availableCommands]
Expand Down Expand Up @@ -2210,7 +2244,7 @@ const ConversationTabView = memo(function ConversationTabView({
selectorsLoading={selectorsLoading}
selectedModeId={selectedModeId}
onModeChange={handleModeChange}
onConfigOptionChange={handleSetConfigOption}
onConfigOptionChange={handleConfigOptionChange}
agentType={selectedAgent}
availableCommands={connectionCommands}
attachmentTabId={tabId}
Expand Down Expand Up @@ -2347,7 +2381,7 @@ const ConversationTabView = memo(function ConversationTabView({
selectorsLoading={selectorsLoading}
selectedModeId={selectedModeId}
onModeChange={handleModeChange}
onConfigOptionChange={handleSetConfigOption}
onConfigOptionChange={handleConfigOptionChange}
agentType={selectedAgent}
availableCommands={connectionCommands}
attachmentTabId={tabId}
Expand Down
Loading
Loading