Fix Assistant resource context and model route labels
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run

This commit is contained in:
rcourtman 2026-06-04 23:45:15 +01:00
parent da7fc95cb3
commit 91528f5a07
19 changed files with 523 additions and 54 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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<AIModelSelectionSectionProps> =
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<AIModelSelectionSectionProps> =
<span class="text-sm font-medium text-base-content">
{AI_SETTINGS_MODEL_OVERRIDES_TITLE}
</span>
<Show when={state.form.chatModel || state.form.patrolModel || state.form.discoveryModel}>
<Show
when={state.form.chatModel || state.form.patrolModel || state.form.discoveryModel}
>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Customized
</span>
@ -424,12 +430,10 @@ export const AIModelSelectionSection: Component<AIModelSelectionSectionProps> =
<PatrolPreflightControl state={state} />
</div>
<div>
<label class="block text-xs font-medium text-muted mb-0.5">
Discovery Model
</label>
<label class="block text-xs font-medium text-muted mb-0.5">Discovery Model</label>
<p class="text-[11px] text-muted mb-1">
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.
</p>
<Show
when={discoveryModelOptions().length > 0}

View file

@ -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<AISettingsStatusAndActionsProps> = (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<AISettingsStatusAndActionsPro
<span class="text-xs font-medium">{state.settingsReadiness().summary}</span>
<Show when={state.settings()?.configured && state.settings()?.model}>
<span class="block sm:inline text-xs opacity-75 sm:ml-2">
Default: {state.settings()?.model?.split(':').pop() || state.settings()?.model}
Default: {defaultModelLabel()}
</span>
</Show>
</div>

View file

@ -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 () => {

View file

@ -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('<select');
expect(aiModelSelectionSectionSource).not.toContain('<optgroup');

View file

@ -5,7 +5,11 @@ import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
import { SearchField } from '@/components/shared/SearchField';
import type { ModelInfo } from '@/types/ai';
import { AI_CHAT_MODEL_SELECTOR_EMPTY_STATE } from '@/utils/aiChatPresentation';
import { getAIProviderDisplayName, getProviderFromModelId } from '@/utils/aiProviderPresentation';
import {
formatAIModelRouteLabel,
getAIProviderDisplayName,
getProviderFromModelId,
} from '@/utils/aiProviderPresentation';
type AIModelPickerDefaultOption = {
label: string;
@ -141,9 +145,9 @@ export const AIModelPicker: Component<AIModelPickerProps> = (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<AIModelPickerProps> = (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' : ''}`}
>
<div class="font-medium text-base-content">
{model.name || model.id.split(':').pop() || model.id}
{formatAIModelRouteLabel(model)}
</div>
<Show when={model.description}>
<div class="line-clamp-2 text-[11px] text-muted">{model.description}</div>

View file

@ -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('<For each={REPORTING_RANGE_OPTIONS}>');
});
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('<TableCard');
}
for (const source of [
unifiedResourceHostTableCardSource,
storageContentCardSource,
]) {
for (const source of [unifiedResourceHostTableCardSource, storageContentCardSource]) {
expect(source).toContain('TableCardHeader');
}
@ -539,8 +532,7 @@ describe('shared primitive guardrails', () => {
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('<table');
expect(source).not.toContain('<thead');
@ -729,7 +721,7 @@ describe('shared primitive guardrails', () => {
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]'",
);

View file

@ -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(() => (
<AIModelPicker
models={routeModels}
selectedModel="openrouter:deepseek/deepseek-v4-pro"
onModelSelect={vi.fn()}
title="Select shared default model"
/>
));
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();
});

View file

@ -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');
});
});

View file

@ -1,4 +1,4 @@
import type { AIProvider } from '@/types/ai';
import type { AIProvider, ModelInfo } from '@/types/ai';
export const AI_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: 'Anthropic',
@ -46,3 +46,47 @@ export function getProviderFromModelId(modelId: string): AIProvider | string {
}
return 'ollama';
}
type AIModelRouteLabelInput =
| string
| (Pick<ModelInfo, 'id'> & Partial<Pick<ModelInfo, 'name' | 'provider'>>);
const GATEWAY_MODEL_PROVIDERS = new Set<string>(['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}`;
};

View file

@ -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 {

View file

@ -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.",

View file

@ -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" {

View file

@ -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)

View file

@ -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",