mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Fix Assistant resource context and model route labels
This commit is contained in:
parent
da7fc95cb3
commit
91528f5a07
19 changed files with 523 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]'",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue