feat(models): resolve active session models through provider adapters

The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.

Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:

- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
  provide a reliable active-session lookup

Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.

Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.

As part of this, centralize two supporting concerns:

- add a shared helper for building the default current-model result from
  a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
  readers and model lookup code enforce the same path-safety rule

Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.

Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
  routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
  provider default when no true active model can be resolved
This commit is contained in:
Haileyesus
2026-05-18 14:54:32 +03:00
parent 556cbd1a03
commit bc5e768579
13 changed files with 537 additions and 52 deletions

View File

@@ -23,6 +23,8 @@ import type {
ApiSuccessShape,
AppErrorOptions,
NormalizedMessage,
ProviderCurrentActiveModel,
ProviderModelsDefinition,
ProviderSkillSource,
WorkspacePathValidationResult,
} from '@/shared/types.js';
@@ -414,6 +416,24 @@ export const readStringRecord = (value: unknown): Record<string, string> | undef
return Object.keys(normalized).length > 0 ? normalized : undefined;
};
// ---------------------------
//----------------- PROVIDER MODEL LOOKUP UTILITIES ------------
/**
* Builds the standard "default current model" result used when a provider
* cannot resolve a session-backed active model.
*
* Provider model adapters should call this after loading their supported model
* catalog so the fallback stays aligned with the provider's current `DEFAULT`
* selection instead of drifting to a hard-coded duplicate.
*/
export function buildDefaultProviderCurrentActiveModel(
models: ProviderModelsDefinition,
): ProviderCurrentActiveModel {
return {
model: models.DEFAULT,
};
}
// ---------------------------
//----------------- WEBSOCKET PAYLOAD PARSING UTILITIES ------------
/**
@@ -742,6 +762,34 @@ export function getOpenCodeDatabasePath(): string {
return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
}
// ---------------------------
//----------------- SAFE DIRECTORY NAME UTILITIES ------------
/**
* Validates that a user or provider supplied identifier can safely be treated
* as one leaf directory name under an existing root folder.
*
* Use this before composing paths like `<root>/<session-id>/file.db>` to block
* path traversal and accidental nested paths. The returned string is trimmed but
* otherwise unchanged so callers can still match the provider's on-disk naming.
*/
export function sanitizeLeafDirectoryName(inputName: string, label = 'directory name'): string {
const normalized = inputName.trim();
if (!normalized) {
throw new Error(`${label} is required.`);
}
if (
normalized.includes('..')
|| normalized.includes(path.posix.sep)
|| normalized.includes(path.win32.sep)
|| normalized !== path.basename(normalized)
) {
throw new Error(`Invalid ${label} "${inputName}".`);
}
return normalized;
}
// ---------------------------
//----------------- SESSION SYNCHRONIZER FILESYSTEM HELPERS ------------
/**