From 91528f5a07199a8cbf03094ef9fd92e28afe3c11 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 4 Jun 2026 23:45:15 +0100 Subject: [PATCH] Fix Assistant resource context and model route labels --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/ai-runtime.md | 11 ++ .../v6/internal/subsystems/api-contracts.md | 12 +- .../subsystems/frontend-primitives.md | 5 + .../internal/subsystems/storage-recovery.md | 6 + .../Settings/AIModelSelectionSection.tsx | 20 ++- .../Settings/AISettingsStatusAndActions.tsx | 11 +- .../Settings/__tests__/AISettings.test.tsx | 45 +++++- .../__tests__/settingsArchitecture.test.ts | 4 + .../src/components/shared/AIModelPicker.tsx | 12 +- .../SharedPrimitives.guardrails.test.ts | 40 ++--- .../shared/__tests__/AIModelPicker.test.tsx | 47 +++++- .../__tests__/aiProviderPresentation.test.ts | 29 ++++ .../src/utils/aiProviderPresentation.ts | 46 +++++- internal/ai/chat/service.go | 92 ++++++++++- .../chat/service_execute_additional_test.go | 146 +++++++++++++++++- internal/api/ai_handler.go | 5 +- internal/api/ai_handler_test.go | 36 +++++ internal/api/contract_test.go | 5 + 19 files changed, 523 insertions(+), 54 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index d9654227b..93cc878e0 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -427,6 +427,11 @@ custom subnet selection a full-row operator control, not a narrow or hidden input target; common subnet chips and the custom subnet field must share the same environment-override and in-flight-save lock semantics as the scan-scope selector. +Adjacent Assistant resource-context handling in `internal/api/ai_handler.go` +may reference agent-backed resources only as selected-resource, model-only +context. It must not mutate agent lifecycle state, start agent discovery, or +grant agent command authority unless the request flows through the governed +agent/action execution contract. Reported agent-backed host profiles must be visible at the source-manager grouping level without changing the canonical connection type: an Unraid agent row stays `agent` internally, but the connected-systems table groups it under diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index b6ab4a696..13dd5d79b 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -81,6 +81,12 @@ runtime cost control, and shared AI transport surfaces. management surface. 5. Add or change AI usage/cost dashboard presentation through `frontend-modern/src/components/AI/AICostDashboard.tsx` and `frontend-modern/src/utils/aiCostPresentation.ts` 6. Add or change AI provider, control-level, or chat/session presentation through `frontend-modern/src/components/AI/Chat/`, `frontend-modern/src/utils/aiProviderPresentation.ts`, `frontend-modern/src/utils/aiProviderHealthPresentation.ts`, `frontend-modern/src/utils/aiControlLevelPresentation.ts`, `frontend-modern/src/utils/aiChatPresentation.ts`, and `frontend-modern/src/utils/aiSessionDiffPresentation.ts` + AI provider/model presentation must preserve the model transport route when + the selected provider is a gateway. OpenRouter-routed model IDs such as + `openrouter:deepseek/...` must render with an explicit `via OpenRouter` + label in the shared picker, System AI settings status, and inherited default + descriptions unless the server-supplied model name already carries that + route. Direct provider models must not gain a gateway label. 7. Keep AI chat presentation helpers aligned through `frontend-modern/src/components/AI/Chat/` and the shared `frontend-modern/src/utils/textPresentation.ts` 8. Keep assistant drawer context, session, and org-switch reset state aligned through the shared `frontend-modern/src/stores/aiChat.ts` boundary instead of letting `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, or feature callers fork their own assistant shell state That shared drawer ownership also covers passive resource reads while the @@ -201,6 +207,11 @@ runtime cost control, and shared AI transport surfaces. tool call, must use the safe handle for scoped reads, must refuse raw provider/config/environment/secret-bearing context expansion, and must not leak configured forbidden resource details in content or tool inputs. + Context-only resource handoff turns must be enforced at the tool-manifest + boundary, not only by prompt wording: unless the operator explicitly asks for + live runtime verification, a read attempt, or discovery execution, the + Assistant loop must receive no tools and must answer from the attached + context or state that Pulse does not currently have that fact. Resource-context model packs and drawer handoff briefings must carry the canonical discovery readiness state (`fresh`, `stale`, `missing`, `running`, `failed`, `unavailable`, or `unsupported`) with provenance and freshness diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 6345ceb5e..56597ebb0 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -413,6 +413,11 @@ payload shape change when the portal presents compact client rows. actions when the run cannot be resolved. Stored session metadata must be readable by the handler so follow-up turns can rehydrate the same backend context without asking the browser to resend provider-bound payloads. + Resource-context follow-up turns are different from Patrol-run rehydration: + browser-safe `handoff_metadata.kind=resource_context` must not replace a + stored rich handoff envelope with a partial metadata-only envelope, and the + handler must not ask the browser to resend resource context that the chat + runtime can rehydrate from the stored selected-resource envelope. Chat stream events are generated from `internal/ai/chat` payload structs into `frontend-modern/src/api/generated/aiChatEvents.ts`; that generated union must not include the retired `explore_status` pre-pass event. Runtime @@ -2973,7 +2978,12 @@ bounded resource/action counts when available. Resource drawer handoffs use `handoff_metadata.kind=resource_context` with a structured `handoff_resources` reference and no browser-authored prompt or model text; the backend chat runtime must hydrate the shared resource context pack from canonical resources -instead of trusting the browser to serialize rich context. Frontend-visible Patrol +instead of trusting the browser to serialize rich context. Context-only +resource handoff questions must remain context-first at the API/runtime +boundary: unless the operator explicitly asks for discovery execution, live +verification, or a read attempt, the runtime withholds tools and returns the +attached context or an explicit missing-fact answer rather than treating the +question as permission to call discovery/read tools. Frontend-visible Patrol assessment briefings must not render recommendation fields as separate title, reason, route-action facts, or prompt chips; the configured model owns those decisions from the structured handoff metadata and bounded chat context. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 6e8de15c0..ca003d750 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -999,6 +999,11 @@ not a replacement status card, CTA band, or page-local nested card. plain select options. The picker must also constrain its dropdown and internal result list to the available viewport height so settings model catalogs remain usable on mobile and tablet layouts with bottom navigation. + Gateway-routed model choices must not look like direct-provider choices: + the shared picker, System AI settings status strip, and per-surface + inherited-default descriptions must render OpenRouter-hosted provider + models with an explicit `via OpenRouter` route label while leaving direct + DeepSeek/OpenAI/Anthropic/Gemini/Ollama selections unqualified. Platform-first top-level pages registered through `frontend-modern/src/App.tsx` must stay chrome-only and route through the canonical app shell: each per-platform surface owns navigation and sub-tab diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 16cc50621..edc79212f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -564,6 +564,12 @@ recovery scope, or a storage/recovery-owned secret source. explanation context, but must not treat the handoff resource or action reference as backup freshness, restore eligibility, recovery execution authority, storage health truth, or a storage-local approval shortcut. + Resource-context Assistant handoffs through `internal/api/ai_handler.go` + follow the same adjacent-boundary rule: storage or recovery resources may + enter Assistant only as selected-resource, model-only context, not as a + provider command, recovery action, raw path/config disclosure, or + storage/recovery execution authority unless a governed action or recovery + contract explicitly owns that operation. That same adjacent boundary also keeps the retired Patrol quickstart contract out of storage/recovery ownership: shared AI handlers no longer expose active quickstart credit, token, or hosted-model provider state, and diff --git a/frontend-modern/src/components/Settings/AIModelSelectionSection.tsx b/frontend-modern/src/components/Settings/AIModelSelectionSection.tsx index 54af358bb..cb1590a24 100644 --- a/frontend-modern/src/components/Settings/AIModelSelectionSection.tsx +++ b/frontend-modern/src/components/Settings/AIModelSelectionSection.tsx @@ -5,7 +5,11 @@ import type { AISettingsState } from '@/components/Settings/useAISettingsState'; import { AIModelPicker } from '@/components/shared/AIModelPicker'; import { formField, labelClass, controlClass } from '@/components/shared/Form'; import { AI_SETTINGS_MODEL_OVERRIDES_TITLE } from '@/utils/aiSettingsPresentation'; -import { getAIProviderDisplayName, getProviderFromModelId } from '@/utils/aiProviderPresentation'; +import { + formatAIModelRouteLabel, + getAIProviderDisplayName, + getProviderFromModelId, +} from '@/utils/aiProviderPresentation'; interface AIModelSelectionSectionProps { state: AISettingsState; @@ -158,7 +162,7 @@ export const AIModelSelectionSection: Component = return ''; } const match = state.availableModels().find((model) => model.id === trimmed); - return match?.name || trimmed.split(':').pop() || trimmed; + return formatAIModelRouteLabel(match || trimmed); }; const selectableModels = (selectedModel: string) => { const selected = selectedModel.trim(); @@ -313,7 +317,9 @@ export const AIModelSelectionSection: Component = {AI_SETTINGS_MODEL_OVERRIDES_TITLE} - + Customized @@ -424,12 +430,10 @@ export const AIModelSelectionSection: Component =
- +

- Used for one-shot service identification on a single resource — a cheaper model - like Haiku is usually sufficient. + Used for one-shot service identification on a single resource — a cheaper model like + Haiku is usually sufficient.

0} diff --git a/frontend-modern/src/components/Settings/AISettingsStatusAndActions.tsx b/frontend-modern/src/components/Settings/AISettingsStatusAndActions.tsx index c1931ad56..260e6d7ab 100644 --- a/frontend-modern/src/components/Settings/AISettingsStatusAndActions.tsx +++ b/frontend-modern/src/components/Settings/AISettingsStatusAndActions.tsx @@ -1,5 +1,6 @@ import { Component, Show } from 'solid-js'; import type { AISettingsState } from '@/components/Settings/useAISettingsState'; +import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation'; interface AISettingsStatusAndActionsProps { state: AISettingsState; @@ -7,6 +8,14 @@ interface AISettingsStatusAndActionsProps { export const AISettingsStatusAndActions: Component = (props) => { const { state } = props; + const defaultModelLabel = () => { + const modelId = state.settings()?.model?.trim(); + if (!modelId) { + return ''; + } + const match = state.availableModels().find((model) => model.id === modelId); + return formatAIModelRouteLabel(match || modelId); + }; return ( <> @@ -20,7 +29,7 @@ export const AISettingsStatusAndActions: Component{state.settingsReadiness().summary} - • Default: {state.settings()?.model?.split(':').pop() || state.settings()?.model} + • Default: {defaultModelLabel()}
diff --git a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx index 9e02706e7..94320a064 100644 --- a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx @@ -326,11 +326,11 @@ describe('AISettings model loading error states', () => { renderComponent(); const pickerButton = await screen.findByTitle('Select shared default model'); - expect(screen.getByText('MiniMax: MiniMax M2.5')).toBeInTheDocument(); + expect(screen.getByText('MiniMax: MiniMax M2.5 via OpenRouter')).toBeInTheDocument(); fireEvent.click(pickerButton); - expect(screen.queryByText('Legacy Model V1')).not.toBeInTheDocument(); + expect(screen.queryByText('Legacy Model V1 via OpenRouter')).not.toBeInTheDocument(); expect(screen.queryByText('Claude Sonnet 4')).not.toBeInTheDocument(); expect(screen.getByText('Show 1 older models')).toBeInTheDocument(); @@ -338,7 +338,46 @@ describe('AISettings model loading error states', () => { target: { value: 'legacy' }, }); - expect(screen.getByText('Legacy Model V1')).toBeInTheDocument(); + expect(screen.getByText('Legacy Model V1 via OpenRouter')).toBeInTheDocument(); + }); + + it('shows the OpenRouter route for a gateway-hosted DeepSeek default model', async () => { + getSettingsMock.mockResolvedValue({ + ...baseSettings(), + enabled: true, + configured: true, + model: 'openrouter:deepseek/deepseek-v4-pro', + openrouter_configured: true, + deepseek_configured: true, + configured_providers: ['openrouter', 'deepseek'], + }); + getModelsMock.mockResolvedValue({ + models: [ + { + id: 'openrouter:deepseek/deepseek-v4-pro', + name: 'DeepSeek: DeepSeek V4 Pro', + provider: 'openrouter', + notable: true, + }, + { + id: 'deepseek:deepseek-v4-pro', + name: 'DeepSeek: DeepSeek V4 Pro', + provider: 'deepseek', + notable: true, + }, + ], + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByTitle('Select shared default model')).toBeInTheDocument(); + }); + + expect(screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter')).toBeInTheDocument(); + expect( + screen.getByText(/Default: DeepSeek: DeepSeek V4 Pro via OpenRouter/), + ).toBeInTheDocument(); }); it('hides autonomous controls when auto-fix is locked and upgrade prompts are hidden', async () => { diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index e7c17c9c5..8e25f87f7 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -3,6 +3,7 @@ import settingsSource from '../Settings.tsx?raw'; import settingsDialogsSource from '../SettingsDialogs.tsx?raw'; import settingsPageShellSource from '../SettingsPageShell.tsx?raw'; import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw'; +import aiSettingsStatusAndActionsSource from '../AISettingsStatusAndActions.tsx?raw'; import aiChatMaintenanceSectionSource from '../AIChatMaintenanceSection.tsx?raw'; import aiModelSelectionSectionSource from '../AIModelSelectionSection.tsx?raw'; import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSection.tsx?raw'; @@ -565,6 +566,9 @@ describe('settings architecture guardrails', () => { expect(aiModelSelectionSectionSource).toContain( 'searchPlaceholder="Search configured provider models"', ); + expect(aiModelSelectionSectionSource).toContain('formatAIModelRouteLabel(match || trimmed)'); + expect(aiSettingsStatusAndActionsSource).toContain('formatAIModelRouteLabel(match || modelId)'); + expect(aiSettingsStatusAndActionsSource).not.toContain("model?.split(':').pop()"); expect(aiModelSelectionSectionSource).not.toContain(' = (props) => { } const match = props.models.find((model) => model.id === selected); if (match) { - return match.name || match.id.split(':').pop() || match.id; + return formatAIModelRouteLabel(match); } - return selected; + return formatAIModelRouteLabel(selected); }); const dropdownStyle = createMemo(() => { @@ -331,7 +335,7 @@ export const AIModelPicker: Component = (props) => { class={`w-full px-3 py-2 text-left text-sm hover:bg-surface-hover ${selectedModel() === model.id ? 'bg-blue-50 dark:bg-blue-900' : ''}`} >
- {model.name || model.id.split(':').pop() || model.id} + {formatAIModelRouteLabel(model)}
{model.description}
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index e85ffa0d3..e1670f371 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -157,9 +157,7 @@ describe('shared primitive guardrails', () => { .map(([path]) => path) .sort(); - expect(rawTableUsers).toEqual([ - './PulseDataGrid.tsx', - ]); + expect(rawTableUsers).toEqual(['./PulseDataGrid.tsx']); }); it('routes canonical settings segmented selectors through FilterButtonGroup', () => { @@ -192,6 +190,12 @@ describe('shared primitive guardrails', () => { expect(reportingPanelSource).not.toContain(''); }); + it('keeps AI model picker labels route-aware for gateway providers', () => { + expect(aiModelPickerSource).toContain('formatAIModelRouteLabel(match)'); + expect(aiModelPickerSource).toContain('formatAIModelRouteLabel(model)'); + expect(aiModelPickerSource).not.toContain("model.id.split(':').pop()"); + }); + it('keeps native form selects on the shared labelled primitive', () => { expect(formSelectSource).toContain("from '@/components/shared/Form'"); expect(formSelectSource).toContain('createUniqueId'); @@ -358,7 +362,6 @@ describe('shared primitive guardrails', () => { expect(workloadSelectionStateSource).toContain('activeSummaryWorkloadGroupScope'); expect(workloadSelectionStateSource).not.toContain('const scrollTop = scroller?.scrollTop'); - expect(summaryTableFocusSource).toContain('export function useSummaryTableFocusBridge'); expect(summaryTableFocusSource).toContain('export function useSummaryPageInteractionState'); expect(summaryTableFocusSource).toContain('resolveSummaryActiveSeriesId'); @@ -377,16 +380,9 @@ describe('shared primitive guardrails', () => { "row.scrollIntoView({ behavior: 'smooth', block: 'center' })", ); expect(summaryTableFocusSource).not.toContain('useNavigate('); - - - }); - it('keeps synchronized summary values on one shared card/readout contract', () => { - - - - }); + it('keeps synchronized summary values on one shared card/readout contract', () => {}); it('keeps summary-linked table row emphasis on the shared active-row presentation contract', () => { expect(frontendIndexCssSource).toContain("tr[data-summary-row-active='true'] > td"); @@ -498,10 +494,7 @@ describe('shared primitive guardrails', () => { expect(source).toContain(' { expect(rolesPanelSource.match(/frame="flush"/g) ?? []).toHaveLength(1); expect(userAssignmentsPanelSource.match(/frame="flush"/g) ?? []).toHaveLength(1); - for (const source of [ - ]) { + for (const source of []) { expect(source).toContain("from '@/components/shared/Table'"); expect(source).not.toContain(' { expect(discoveryReadinessBadgeSource).toContain( "import type { DiscoveryReadinessPresentation } from '@/utils/resourceDiscoveryReadiness'", ); - expect(discoveryReadinessBadgeSource).toContain("aria-label={presentation()?.title"); + expect(discoveryReadinessBadgeSource).toContain('aria-label={presentation()?.title'); expect(discoveryReadinessBadgeSource).toContain('title={presentation()?.title}'); expect(discoveryReadinessBadgeSource).toContain('aria-hidden="true"'); expect(discoveryReadinessBadgeSource).not.toContain('fetch('); @@ -758,15 +750,10 @@ describe('shared primitive guardrails', () => { expect(guestRowSource).toContain("from '@/components/shared/TagBadges'"); expect(guestRowSource).not.toContain("from './TagBadges'"); expect(resourceDetailSummarySource).toContain("from '@/components/shared/TagBadges'"); - expect(resourceDetailSummarySource).not.toContain( - "from '@/components/Workloads/TagBadges'", - ); + expect(resourceDetailSummarySource).not.toContain("from '@/components/Workloads/TagBadges'"); }); - it('keeps interactive sparkline on shell, runtime, and model owners', () => { - - - }); + it('keeps interactive sparkline on shell, runtime, and model owners', () => {}); it('keeps dialog on shell, runtime, and model owners', () => { expect(dialogSource).toContain('useDialogState'); @@ -1065,7 +1052,6 @@ describe('shared primitive guardrails', () => { }); it('keeps summary density control inside the shared summary primitives', () => { - expect(summaryChartLayoutSource).toContain( "export const SUMMARY_CHART_SLOT_CLASS = 'h-[136px] sm:h-[150px]'", ); diff --git a/frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx b/frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx index b956e2b91..9e88f7389 100644 --- a/frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx @@ -14,12 +14,14 @@ const models: ModelInfo[] = [ name: 'MiniMax: MiniMax M2.5', description: 'Current OpenRouter model', notable: true, + provider: 'openrouter', }, { id: 'openrouter:legacy/model-v1', name: 'Legacy Model V1', description: 'Older provider catalog entry', notable: false, + provider: 'openrouter', }, { id: 'openai:gpt-5.1-mini', @@ -41,12 +43,47 @@ describe('AIModelPicker', () => { fireEvent.click(screen.getByTitle('Select shared default model')); - expect(screen.getAllByText('MiniMax: MiniMax M2.5').length).toBeGreaterThan(0); + expect(screen.getAllByText('MiniMax: MiniMax M2.5 via OpenRouter').length).toBeGreaterThan(0); expect(screen.getByText('GPT-5.1 Mini')).toBeInTheDocument(); - expect(screen.queryByText('Legacy Model V1')).not.toBeInTheDocument(); + expect(screen.queryByText('Legacy Model V1 via OpenRouter')).not.toBeInTheDocument(); expect(screen.getByText('Show 1 older models')).toBeInTheDocument(); }); + it('labels gateway-routed selected models with the transport provider', () => { + const routeModels: ModelInfo[] = [ + { + id: 'openrouter:deepseek/deepseek-v4-pro', + name: 'DeepSeek: DeepSeek V4 Pro', + provider: 'openrouter', + notable: true, + }, + { + id: 'deepseek:deepseek-v4-pro', + name: 'DeepSeek: DeepSeek V4 Pro', + provider: 'deepseek', + notable: true, + }, + ]; + + render(() => ( + + )); + + expect(screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter')).toBeInTheDocument(); + + fireEvent.click(screen.getByTitle('Select shared default model')); + + expect(screen.getAllByText('DeepSeek: DeepSeek V4 Pro via OpenRouter').length).toBeGreaterThan( + 0, + ); + expect(screen.getByText('DeepSeek: DeepSeek V4 Pro')).toBeInTheDocument(); + }); + it('constrains the dropdown to the available mobile viewport height', () => { vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(760); vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(850); @@ -94,7 +131,7 @@ describe('AIModelPicker', () => { target: { value: 'legacy' }, }); - expect(screen.getByText('Legacy Model V1')).toBeInTheDocument(); + expect(screen.getByText('Legacy Model V1 via OpenRouter')).toBeInTheDocument(); expect(screen.queryByText('Show 1 older models')).not.toBeInTheDocument(); }); @@ -114,7 +151,7 @@ describe('AIModelPicker', () => { target: { value: 'minimax' }, }); - expect(screen.getByText('MiniMax: MiniMax M2.5')).toBeInTheDocument(); + expect(screen.getByText('MiniMax: MiniMax M2.5 via OpenRouter')).toBeInTheDocument(); expect(screen.queryByText('Use "minimax"')).not.toBeInTheDocument(); fireEvent.keyDown(screen.getByPlaceholderText('Search or enter model ID'), { key: 'Enter' }); @@ -134,7 +171,7 @@ describe('AIModelPicker', () => { fireEvent.click(screen.getByTitle('Select shared default model')); - expect(screen.getAllByText('Legacy Model V1').length).toBeGreaterThan(0); + expect(screen.getAllByText('Legacy Model V1 via OpenRouter').length).toBeGreaterThan(0); expect(screen.queryByText('Show 1 older models')).not.toBeInTheDocument(); }); diff --git a/frontend-modern/src/utils/__tests__/aiProviderPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiProviderPresentation.test.ts index 7a726fb5f..acf1100b9 100644 --- a/frontend-modern/src/utils/__tests__/aiProviderPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiProviderPresentation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { AI_PROVIDER_DISPLAY_NAMES, + formatAIModelRouteLabel, getAIProviderDisplayName, getProviderFromModelId, } from '@/utils/aiProviderPresentation'; @@ -22,4 +23,32 @@ describe('aiProviderPresentation', () => { expect(getProviderFromModelId('gemini-2.5-pro')).toBe('gemini'); expect(getProviderFromModelId('llama3.1')).toBe('ollama'); }); + + it('keeps OpenRouter-routed model labels distinct from direct provider models', () => { + expect( + formatAIModelRouteLabel({ + id: 'openrouter:deepseek/deepseek-v4-pro', + name: 'DeepSeek: DeepSeek V4 Pro', + provider: 'openrouter', + }), + ).toBe('DeepSeek: DeepSeek V4 Pro via OpenRouter'); + + expect( + formatAIModelRouteLabel({ + id: 'deepseek:deepseek-v4-pro', + name: 'DeepSeek: DeepSeek V4 Pro', + provider: 'deepseek', + }), + ).toBe('DeepSeek: DeepSeek V4 Pro'); + }); + + it('does not duplicate an existing OpenRouter route label', () => { + expect( + formatAIModelRouteLabel({ + id: 'openrouter:deepseek/deepseek-r1', + name: 'DeepSeek R1 via OpenRouter', + provider: 'openrouter', + }), + ).toBe('DeepSeek R1 via OpenRouter'); + }); }); diff --git a/frontend-modern/src/utils/aiProviderPresentation.ts b/frontend-modern/src/utils/aiProviderPresentation.ts index 97d026ddb..07e0586ad 100644 --- a/frontend-modern/src/utils/aiProviderPresentation.ts +++ b/frontend-modern/src/utils/aiProviderPresentation.ts @@ -1,4 +1,4 @@ -import type { AIProvider } from '@/types/ai'; +import type { AIProvider, ModelInfo } from '@/types/ai'; export const AI_PROVIDER_DISPLAY_NAMES: Record = { anthropic: 'Anthropic', @@ -46,3 +46,47 @@ export function getProviderFromModelId(modelId: string): AIProvider | string { } return 'ollama'; } + +type AIModelRouteLabelInput = + | string + | (Pick & Partial>); + +const GATEWAY_MODEL_PROVIDERS = new Set(['openrouter']); + +const modelIdForLabel = (model: AIModelRouteLabelInput): string => + typeof model === 'string' ? model.trim() : model.id.trim(); + +const baseModelLabel = (model: AIModelRouteLabelInput): string => { + if (typeof model !== 'string') { + const name = model.name?.trim(); + if (name) { + return name; + } + } + const id = modelIdForLabel(model); + return id.split(':').pop() || id; +}; + +const transportProviderForLabel = (model: AIModelRouteLabelInput): string => { + if (typeof model !== 'string') { + const provider = model.provider?.trim(); + if (provider) { + return provider; + } + } + const id = modelIdForLabel(model); + return id ? getProviderFromModelId(id) : ''; +}; + +export const formatAIModelRouteLabel = (model: AIModelRouteLabelInput): string => { + const label = baseModelLabel(model); + const provider = transportProviderForLabel(model); + if (!provider || !GATEWAY_MODEL_PROVIDERS.has(provider)) { + return label; + } + const providerName = getAIProviderDisplayName(provider); + if (!providerName || label.toLowerCase().includes(providerName.toLowerCase())) { + return label; + } + return `${label} via ${providerName}`; +}; diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index e288f851b..67b7ad635 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -531,11 +531,19 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac handoffActions := normalizeHandoffActions(req.HandoffActions) handoffMetadata := NormalizeHandoffMetadata(req.HandoffMetadata) handoffFindingID := strings.TrimSpace(req.FindingID) + handoffMetadataCarriesEnvelope := !handoffMetadataEmpty(handoffMetadata) + if handoffMetadata.Kind == sessionHandoffKindResourceContext && + handoffFindingID == "" && + handoffContext == "" && + len(handoffResources) == 0 && + len(handoffActions) == 0 { + handoffMetadataCarriesEnvelope = false + } hasRequestHandoffEnvelope := handoffFindingID != "" || handoffContext != "" || len(handoffResources) > 0 || len(handoffActions) > 0 || - !handoffMetadataEmpty(handoffMetadata) + handoffMetadataCarriesEnvelope if hasRequestHandoffEnvelope { if err := sessions.SetModelHandoffEnvelope(session.ID, handoffFindingID, handoffContext, handoffResources, handoffActions, handoffMetadata); err != nil { log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist model handoff envelope") @@ -774,6 +782,12 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac // Run agentic loop filteredTools := s.toolsForExecutionMode(autonomousMode, false) + if resourceContextTurnIsContextOnly(req.Prompt, handoffResources, handoffMetadata) { + filteredTools = []providers.Tool{} + log.Debug(). + Str("session_id", session.ID). + Msg("[ChatService] Withholding tools for context-only resource handoff turn") + } log.Debug(). Str("session_id", session.ID). Int("tools_count", len(filteredTools)). @@ -832,6 +846,10 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac } streamCallback(event) } + sessionData, _ := json.Marshal(struct { + ID string `json:"id"` + }{ID: session.ID}) + streamCallback(StreamEvent{Type: "session", Data: sessionData}) resultMessages, err := loop.ExecuteWithTools(ctx, session.ID, messages, filteredTools, wrappedCallback) resultMessages = sanitizeMessagesForHandoffResourcePolicy(resultMessages, handoffResources, handoffResourceProvider) @@ -1125,13 +1143,83 @@ func buildResourceContextHandoffDirective(handoffResources []HandoffResource, me "Source: Pulse resource drawer handoff", "Selected Resource: The attached handoff resource is the user's current selected resource. Do not ask which server, service, container, VM, or resource the user means.", "Tool Target Handle: When you need a read-only tool against the attached resource, use target_host=\"current_resource\" or resource_id=\"current_resource\". Do not copy 'redacted by policy' into any tool argument.", - "Discovery Boundary: Do not call discovery tools only to identify this resource. Use the attached resource context first; call read-only tools only when the user asks for fresh runtime verification or a missing fact cannot be answered from context.", + "Context-First Answering: When the user asks what Pulse already knows, asks for discovery readiness, or asks a question that should be answerable from discovered/service context, answer from the attached context without tools. If the attached context lacks the fact, say that Pulse does not currently have that discovery/context fact instead of filling the gap with tools.", + "Discovery Boundary: Do not call discovery tools only to identify this resource or fill in missing context. Use attached discovery readiness first; call discovery only when the user explicitly asks you to run discovery.", + "Read Tool Boundary: Call read-only tools against current_resource only when the user explicitly asks you to investigate live runtime state, asks for fresh verification, or specifically requests a read attempt. Do not use read or mixed tools just to improve a context summary.", "Data Boundary: Do not reveal or reconstruct raw provider commands, config paths, environment variables, bind mounts, Docker labels, or secret-bearing metadata. If asked for those details, say they are withheld or redacted and offer a safe summary.", "Raw Context Requests: If asked to print, expand, reconstruct, or reveal raw context details, start with exactly this boundary: \"Raw context details are withheld by policy.\" Then give only a safe summary.", "Action Boundary: Context is read-only and grants no approval or execution authority. Any action requires the governed approval/action flow.", }, "\n") } +func resourceContextTurnIsContextOnly(prompt string, handoffResources []HandoffResource, metadata HandoffMetadata) bool { + metadata = NormalizeHandoffMetadata(metadata) + if metadata.Kind != sessionHandoffKindResourceContext || len(normalizeHandoffResources(handoffResources)) == 0 { + return false + } + text := strings.ToLower(strings.Join(strings.Fields(prompt), " ")) + if text == "" { + return true + } + if resourceContextPromptDisallowsTools(text) { + return true + } + return !resourceContextPromptExplicitlyRequestsTools(text) +} + +func resourceContextPromptDisallowsTools(text string) bool { + for _, phrase := range []string{ + "before using any tools", + "without using tools", + "without running tools", + "do not use tools", + "do not run tools", + "don't use tools", + "don't run tools", + "no tools", + } { + if strings.Contains(text, phrase) { + return true + } + } + return false +} + +func resourceContextPromptExplicitlyRequestsTools(text string) bool { + for _, phrase := range []string{ + "pulse_read", + "read-only pulse_read", + "read-only tool", + "read tool", + "tool attempt", + "use tools", + "use a tool", + "run tools", + "run a tool", + "call tools", + "call a tool", + "live runtime", + "fresh runtime verification", + "fresh verification", + "verify live", + "live check", + "check logs", + "read logs", + "tail logs", + "inspect live", + "run discovery", + "call discovery", + "use discovery", + "start discovery", + "scan this resource", + } { + if strings.Contains(text, phrase) { + return true + } + } + return false +} + func buildHandoffResourcePolicyContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string { resources := canonicalHandoffResources(handoffResources, provider) if len(resources) == 0 { diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index a1427ab00..c95a89ba8 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -3,6 +3,7 @@ package chat import ( "context" "encoding/json" + "reflect" "strings" "sync/atomic" "testing" @@ -344,10 +345,20 @@ func TestService_ExecuteStream_Success(t *testing.T) { } var doneCount int + var sessionIDs []string callback := func(event StreamEvent) { if event.Type == "done" { doneCount++ } + if event.Type == "session" { + var data struct { + ID string `json:"id"` + } + if err := json.Unmarshal(event.Data, &data); err != nil { + t.Fatalf("unmarshal session event: %v", err) + } + sessionIDs = append(sessionIDs, data.ID) + } } req := ExecuteRequest{ @@ -360,6 +371,9 @@ func TestService_ExecuteStream_Success(t *testing.T) { if doneCount != 1 { t.Fatalf("expected done callback once, got %d", doneCount) } + if !reflect.DeepEqual(sessionIDs, []string{"sess-1"}) { + t.Fatalf("session events = %#v, want sess-1", sessionIDs) + } messages, err := store.GetMessages("sess-1") if err != nil { @@ -429,6 +443,134 @@ func TestService_ExecuteStream_InteractiveChatLetsModelChooseTools(t *testing.T) } } +func TestResourceContextTurnIsContextOnly(t *testing.T) { + resources := []HandoffResource{{ID: "system-container:ha-node:101", Name: "homeassistant", Type: "system-container"}} + metadata := HandoffMetadata{Kind: "resource_context"} + + tests := []struct { + name string + prompt string + want bool + }{ + { + name: "context summary defaults to no tools", + prompt: "What does Pulse already know about this resource?", + want: true, + }, + { + name: "explicit no tools wins", + prompt: "Before using any tools, tell me the discovery readiness.", + want: true, + }, + { + name: "natural automation question stays context first", + prompt: "How long before my blinds automation runs?", + want: true, + }, + { + name: "explicit read attempt allows tools", + prompt: "Make one read-only pulse_read attempt against the attached resource.", + want: false, + }, + { + name: "explicit discovery request allows tools", + prompt: "Run discovery for this resource now.", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resourceContextTurnIsContextOnly(tt.prompt, resources, metadata); got != tt.want { + t.Fatalf("resourceContextTurnIsContextOnly() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestService_ExecuteStream_ResourceContextToolManifestPolicy(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewSessionStore(tmpDir) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + + var toolCounts []int + provider := &stubServiceProvider{ + streamFn: func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error { + toolCounts = append(toolCounts, len(req.Tools)) + callback(providers.StreamEvent{ + Type: "content", + Data: providers.ContentEvent{Text: "Assistant is ready."}, + }) + callback(providers.StreamEvent{ + Type: "done", + Data: providers.DoneEvent{InputTokens: 1, OutputTokens: 1}, + }) + return nil + }, + } + + service := NewService(Config{ + AIConfig: &config.AIConfig{ControlLevel: config.ControlLevelControlled}, + StateProvider: &mockStateProvider{}, + AgentServer: &mockAgentServer{}, + }) + service.sessions = store + service.provider = provider + service.started = true + + handoffResources := []HandoffResource{{ + ID: "system-container:ha-node:101", + Name: "homeassistant", + Type: "system-container", + Node: "ha-node", + }} + handoffMetadata := HandoffMetadata{Kind: "resource_context"} + + if err := service.ExecuteStream(context.Background(), ExecuteRequest{ + SessionID: "resource-context-tool-policy", + Prompt: "What does Pulse already know about this resource?", + HandoffResources: handoffResources, + HandoffMetadata: handoffMetadata, + }, func(StreamEvent) {}); err != nil { + t.Fatalf("context-only ExecuteStream failed: %v", err) + } + if err := service.ExecuteStream(context.Background(), ExecuteRequest{ + SessionID: "resource-context-tool-policy", + Prompt: "Before using any tools, tell me whether Pulse has fresh discovery data for this attached resource.", + HandoffMetadata: handoffMetadata, + }, func(StreamEvent) {}); err != nil { + t.Fatalf("metadata-only follow-up ExecuteStream failed: %v", err) + } + storedResources, err := store.GetModelHandoffResources("resource-context-tool-policy") + if err != nil { + t.Fatalf("GetModelHandoffResources failed: %v", err) + } + if len(storedResources) != 1 || storedResources[0].ID != handoffResources[0].ID { + t.Fatalf("stored handoff resources = %#v, want preserved resource scope", storedResources) + } + if err := service.ExecuteStream(context.Background(), ExecuteRequest{ + SessionID: "resource-context-tool-policy", + Prompt: "Make one read-only pulse_read attempt against the attached resource.", + }, func(StreamEvent) {}); err != nil { + t.Fatalf("explicit-read ExecuteStream failed: %v", err) + } + + if len(toolCounts) != 3 { + t.Fatalf("toolCounts = %#v, want three provider calls", toolCounts) + } + if toolCounts[0] != 0 { + t.Fatalf("context-only turn exposed %d tools, want 0", toolCounts[0]) + } + if toolCounts[1] != 0 { + t.Fatalf("metadata-only follow-up exposed %d tools, want 0", toolCounts[1]) + } + if toolCounts[2] == 0 { + t.Fatal("explicit read turn exposed no tools") + } +} + func TestService_ExecuteStream_RequestAutonomousOverrideClampsToolExecutor(t *testing.T) { installTestApprovalStore(t, nil) @@ -960,7 +1102,9 @@ func TestService_ExecuteStream_ResourceContextHandoffDirectiveAndOutputRedaction "Selected Resource: The attached handoff resource is the user's current selected resource.", "Do not ask which server, service, container, VM, or resource the user means.", "Tool Target Handle: When you need a read-only tool against the attached resource, use target_host=\"current_resource\" or resource_id=\"current_resource\".", - "Discovery Boundary: Do not call discovery tools only to identify this resource.", + "Context-First Answering: When the user asks what Pulse already knows, asks for discovery readiness, or asks a question that should be answerable from discovered/service context, answer from the attached context without tools.", + "Discovery Boundary: Do not call discovery tools only to identify this resource or fill in missing context.", + "Read Tool Boundary: Call read-only tools against current_resource only when the user explicitly asks you to investigate live runtime state, asks for fresh verification, or specifically requests a read attempt.", "Data Boundary: Do not reveal or reconstruct raw provider commands, config paths, environment variables, bind mounts, Docker labels, or secret-bearing metadata.", "Raw Context Requests: If asked to print, expand, reconstruct, or reveal raw context details, start with exactly this boundary: \"Raw context details are withheld by policy.\" Then give only a safe summary.", "Action Boundary: Context is read-only and grants no approval or execution authority.", diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index e469afaab..68e89a1fd 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -2439,7 +2439,10 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) { if storedMetadata, err := svc.GetModelHandoffMetadata(ctx, req.SessionID); err != nil { log.Debug().Err(err).Str("session_id", req.SessionID).Msg("Unable to load stored Assistant handoff metadata") } else { - handoffMetadata = chat.NormalizeHandoffMetadata(storedMetadata) + storedMetadata = chat.NormalizeHandoffMetadata(storedMetadata) + if storedMetadata.Kind == "patrol_run" { + handoffMetadata = storedMetadata + } } } if findingID == "" && handoffMetadata.Kind == "patrol_run" { diff --git a/internal/api/ai_handler_test.go b/internal/api/ai_handler_test.go index 5348b1339..faf6590bb 100644 --- a/internal/api/ai_handler_test.go +++ b/internal/api/ai_handler_test.go @@ -909,6 +909,42 @@ func TestHandleChat_RehydratesStoredPatrolRunHandoffMetadataForFollowUp(t *testi assert.Equal(t, http.StatusOK, w.Code) } +func TestHandleChat_DoesNotRehydrateStoredResourceContextMetadataAsPartialEnvelope(t *testing.T) { + cfg := &config.Config{} + h := newTestAIHandler(cfg, nil, nil) + mockSvc := new(MockAIService) + h.defaultService = mockSvc + + mockSvc.On("IsRunning").Return(true) + mockSvc. + On("GetModelHandoffFindingID", mock.Anything, "session-resource"). + Return("", nil) + mockSvc. + On("GetModelHandoffMetadata", mock.Anything, "session-resource"). + Return(chat.HandoffMetadata{Kind: "resource_context"}, nil) + mockSvc. + On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + reqArg := args.Get(1).(chat.ExecuteRequest) + assert.Equal(t, "Before using any tools, report discovery readiness from context.", reqArg.Prompt) + assert.Empty(t, reqArg.FindingID) + assert.Empty(t, reqArg.HandoffContext) + assert.Empty(t, reqArg.HandoffResources) + assert.Empty(t, reqArg.HandoffActions) + assert.Equal(t, chat.HandoffMetadata{}, reqArg.HandoffMetadata) + }) + + body := `{"prompt":"Before using any tools, report discovery readiness from context.","session_id":"session-resource"}` + req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.HandleChat(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockSvc.AssertExpectations(t) +} + func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) { cfg := &config.Config{} h := newTestAIHandler(cfg, nil, nil) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 42bd4b028..0f78a6854 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -355,6 +355,9 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { `HandoffMetadata chat.HandoffMetadata ` + "`json:\"handoff_metadata,omitempty\"`", "handoffActions := normalizeChatRequestHandoffActions(req.HandoffActions)", "handoffMetadata := chat.NormalizeHandoffMetadata(req.HandoffMetadata)", + "storedMetadata = chat.NormalizeHandoffMetadata(storedMetadata)", + `if storedMetadata.Kind == "patrol_run"`, + "handoffMetadata = storedMetadata", "h.getPatrolRunForHandoff(ctx, handoffMetadata.RunID)", "airuntime.BuildPatrolRunAssistantHandoff(run)", "chatRequestHandoffActionLimit", @@ -466,6 +469,8 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { "handoffContext := strings.TrimSpace(req.HandoffContext)", "handoffFindingID := strings.TrimSpace(req.FindingID)", "handoffMetadata := NormalizeHandoffMetadata(req.HandoffMetadata)", + "handoffMetadataCarriesEnvelope", + "handoffMetadata.Kind == sessionHandoffKindResourceContext", "hasRequestHandoffEnvelope", "sessions.SetModelHandoffEnvelope(session.ID, handoffFindingID, handoffContext, handoffResources, handoffActions, handoffMetadata)", "ClearModelHandoffContext",