mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Clarify Assistant route recovery actions
This commit is contained in:
@@ -454,7 +454,7 @@ remain visible/inspectable.
|
||||
treating provider/model changes as explicit session events in
|
||||
`packages/core/src/session/event.ts` and
|
||||
`packages/opencode/src/session/prompt.ts`. Pulse adapts that behavior for the
|
||||
drawer instead of implementing automatic cross-provider fallback.
|
||||
drawer instead of implementing automatic cross-provider route switching.
|
||||
Primary interactive chat model resolution must use the explicit configured
|
||||
chat route or a stable provider default without calling provider model
|
||||
catalogs before the selected stream starts. Catalog-backed recommendation
|
||||
@@ -484,8 +484,8 @@ remain visible/inspectable.
|
||||
they come from explicit route recovery or restored historical data, but the UI
|
||||
must label them neutrally as selected or switched routes rather than as
|
||||
automatic provider fallback, and completing a streamed route-switch row must
|
||||
not promote a fallback route into the active model selection without an
|
||||
explicit user action.
|
||||
not promote that route into the active model selection without an explicit
|
||||
user action.
|
||||
Interactive Assistant streams must establish the session ID and emit the
|
||||
`session` event once as soon as the HTTP SSE writer is ready, before finding
|
||||
handoff recovery, model resolution, selected-provider startup,
|
||||
@@ -2156,6 +2156,20 @@ autocomplete hides unavailable local commands, command help may show them
|
||||
disabled with a reason, and manual slash submissions respect the same
|
||||
availability before running local session actions.
|
||||
|
||||
Assistant provider-route recovery must read as explicit operator action, not
|
||||
automatic fallback. The OpenCode reference at fetched `origin/dev` commit
|
||||
`914a643` retries the selected provider/model route through
|
||||
`packages/opencode/src/session/retry.ts` and
|
||||
`packages/opencode/src/session/processor.ts` by passing
|
||||
`input.model.providerID` into `SessionRetry.policy`, renders retry state in
|
||||
`packages/ui/src/components/session-retry.tsx`, and records provider/model
|
||||
changes as explicit `ModelSwitched` session events in
|
||||
`packages/core/src/session/event.ts` plus `packages/opencode/src/session/prompt.ts`.
|
||||
Pulse adapts that by keeping same-route retry visible, ignoring obsolete
|
||||
provider route-switch metadata without changing the selected route, and labeling
|
||||
failed-turn or readiness recovery buttons as explicit route/model-route choices
|
||||
instead of implying automatic route adoption.
|
||||
|
||||
Assistant tool activity now follows an OpenCode-referenced chronological row
|
||||
model where appropriate for Pulse. Consecutive context/read/query tools render
|
||||
as visible transcript rows in arrival order instead of being replaced by a
|
||||
@@ -2247,8 +2261,9 @@ the prompt and active/retry/interrupt state in the prompt footer in
|
||||
`packages/tui/src/component/prompt/index.tsx`, with broader system status kept
|
||||
in `packages/tui/src/routes/session/footer.tsx`. Pulse adapts that pattern for
|
||||
the web drawer by keeping model route, recent-route cycling, control mode,
|
||||
last-turn usage, active workflow progress, queued follow-ups, fallback notices,
|
||||
and the autonomous control warning in the input-adjacent composer/status rail.
|
||||
last-turn usage, active workflow progress, queued follow-ups, route-recovery
|
||||
notices, and the autonomous control warning in the input-adjacent
|
||||
composer/status rail.
|
||||
Those items stay visible and actionable, but they do not compete with the
|
||||
transcript as separate top-of-drawer banners unless they are provider readiness
|
||||
or scoped handoff context surfaces with their own governed content.
|
||||
|
||||
@@ -360,6 +360,13 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
if (!model) return '';
|
||||
return props.getModelRouteLabel?.(model) || formatAIModelRouteLabel(model);
|
||||
};
|
||||
const modelRouteRecoveryButtonLabel = () => {
|
||||
const alternative = props.modelRouteAlternative;
|
||||
if (!alternative) return '';
|
||||
return alternative.kind === 'same-model-route'
|
||||
? `Retry with ${alternative.providerLabel} route`
|
||||
: `Retry with ${alternative.providerLabel} model route`;
|
||||
};
|
||||
const hasPreviousModelRoute = (event: StreamDisplayEvent) => {
|
||||
const model = event.model?.trim();
|
||||
const failed = event.failedModel?.trim();
|
||||
@@ -804,12 +811,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
onClick={() =>
|
||||
props.onUseModelRoute?.(alternative().id, props.message.id)
|
||||
}
|
||||
aria-label={`Retry via ${alternative().providerLabel} provider route`}
|
||||
aria-label={modelRouteRecoveryButtonLabel()}
|
||||
title={alternative().label}
|
||||
class="inline-flex max-w-[14rem] items-center gap-1.5 rounded-md border border-red-300 bg-white/80 px-2 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/20 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<CpuIcon class="h-3.5 w-3.5" />
|
||||
<span class="truncate">Retry via {alternative().providerLabel}</span>
|
||||
<span class="truncate">{modelRouteRecoveryButtonLabel()}</span>
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -288,7 +288,9 @@ vi.mock('../ChatMessages', () => ({
|
||||
props.onUseModelRoute?.(recovery().alternative.id, recovery().failedMessage.id)
|
||||
}
|
||||
>
|
||||
Retry via {recovery().alternative.providerLabel}
|
||||
{recovery().alternative.kind === 'same-model-route'
|
||||
? `Retry with ${recovery().alternative.providerLabel} route`
|
||||
: `Retry with ${recovery().alternative.providerLabel} model route`}
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
@@ -853,6 +855,13 @@ describe('AIChat', () => {
|
||||
|
||||
fireEvent.click(await screen.findByTestId('mock-use-model-route'));
|
||||
|
||||
const props = mockChatMessagesProps[mockChatMessagesProps.length - 1];
|
||||
const alternative = props.getModelRouteAlternative?.(mockChat.messages()[0]);
|
||||
expect(alternative).toMatchObject({
|
||||
id: 'openrouter:deepseek/deepseek-v4-pro',
|
||||
kind: 'same-model-route',
|
||||
provider: 'openrouter',
|
||||
});
|
||||
expect(mockChat.setModel).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro');
|
||||
expect(mockChat.retryMessage).toHaveBeenCalledWith('assistant-error-1', {
|
||||
model: 'openrouter:deepseek/deepseek-v4-pro',
|
||||
@@ -917,7 +926,7 @@ describe('AIChat', () => {
|
||||
expect(screen.queryByTestId('mock-use-model-route')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to another configured provider after equivalent routes have already failed', async () => {
|
||||
it('offers an explicit alternate model route after equivalent routes have already failed', async () => {
|
||||
const openRouterFailure: ChatMessage = {
|
||||
id: 'assistant-error-openrouter',
|
||||
role: 'assistant',
|
||||
@@ -980,6 +989,7 @@ describe('AIChat', () => {
|
||||
|
||||
expect(alternative).toMatchObject({
|
||||
id: 'openai:gpt-4o',
|
||||
kind: 'alternate-model-route',
|
||||
provider: 'openai',
|
||||
providerLabel: 'OpenAI',
|
||||
});
|
||||
@@ -1230,9 +1240,9 @@ describe('AIChat', () => {
|
||||
renderChat();
|
||||
|
||||
const switchButton = await screen.findByRole('button', {
|
||||
name: 'Use OpenRouter provider route',
|
||||
name: 'Use OpenRouter route',
|
||||
});
|
||||
expect(switchButton).toHaveTextContent('Use OpenRouter');
|
||||
expect(switchButton).toHaveTextContent('Use OpenRouter route');
|
||||
|
||||
fireEvent.click(switchButton);
|
||||
|
||||
@@ -1281,7 +1291,7 @@ describe('AIChat', () => {
|
||||
renderChat();
|
||||
|
||||
await screen.findByRole('button', {
|
||||
name: 'Use OpenRouter provider route',
|
||||
name: 'Use OpenRouter route',
|
||||
});
|
||||
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
@@ -4648,7 +4658,7 @@ describe('AIChat', () => {
|
||||
]);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('status', { name: 'Assistant fallback route adopted' }),
|
||||
screen.queryByRole('status', { name: 'Assistant model route switch adopted' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockChat.setModel).not.toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro');
|
||||
expect(mockNotificationStore.success).not.toHaveBeenCalledWith(
|
||||
|
||||
@@ -429,6 +429,7 @@ describe('ChatMessages', () => {
|
||||
it('forwards message-specific route recovery options for failed turns', () => {
|
||||
const routeAlternative: ModelRouteRecoveryOption = {
|
||||
id: 'openrouter:deepseek/deepseek-v4-pro',
|
||||
kind: 'same-model-route',
|
||||
label: 'DeepSeek: DeepSeek V4 Pro via OpenRouter',
|
||||
provider: 'openrouter',
|
||||
providerLabel: 'OpenRouter',
|
||||
|
||||
@@ -230,6 +230,7 @@ describe('MessageItem', () => {
|
||||
onChangeModel={onChangeModel}
|
||||
modelRouteAlternative={{
|
||||
id: 'openrouter:deepseek/deepseek-v4-pro',
|
||||
kind: 'same-model-route',
|
||||
label: 'DeepSeek: DeepSeek V4 Pro via OpenRouter',
|
||||
provider: 'openrouter',
|
||||
providerLabel: 'OpenRouter',
|
||||
@@ -243,9 +244,9 @@ describe('MessageItem', () => {
|
||||
expect(alert.textContent).toContain('billing or quota reasons');
|
||||
|
||||
const retryViaOpenRouter = screen.getByRole('button', {
|
||||
name: 'Retry via OpenRouter provider route',
|
||||
name: 'Retry with OpenRouter route',
|
||||
});
|
||||
expect(retryViaOpenRouter).toHaveTextContent('Retry via OpenRouter');
|
||||
expect(retryViaOpenRouter).toHaveTextContent('Retry with OpenRouter route');
|
||||
fireEvent.click(retryViaOpenRouter);
|
||||
expect(onUseModelRoute).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro', 'msg-1');
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ describe('Assistant transcript export', () => {
|
||||
expect(toolIndex).toBeLessThan(answerIndex);
|
||||
});
|
||||
|
||||
it('formats initially selected model route events distinctly from fallback switches', () => {
|
||||
it('formats initially selected model route events distinctly from route switches', () => {
|
||||
const transcript = formatAssistantTranscript({
|
||||
messages: [
|
||||
{
|
||||
|
||||
@@ -1331,7 +1331,7 @@ describe('useChat', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('surfaces obsolete provider fallback workflow metadata without switching routes', async () => {
|
||||
it('surfaces obsolete provider route-switch workflow metadata without switching routes', async () => {
|
||||
const { getFireEvent } = setupWithEventCapture();
|
||||
const { value: chat, dispose } = withRoot(() =>
|
||||
useChat({ sessionId: 's', model: 'openrouter:openai/gpt-4o-mini' }),
|
||||
@@ -1355,7 +1355,7 @@ describe('useChat', () => {
|
||||
expect(assistant.workflowStatus).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'provider_fallback_rejected',
|
||||
message: 'Provider fallback metadata received; selected route unchanged.',
|
||||
message: 'Provider route-switch metadata ignored; selected route unchanged.',
|
||||
}),
|
||||
);
|
||||
expect(assistant.streamEvents).toEqual(
|
||||
@@ -1364,7 +1364,7 @@ describe('useChat', () => {
|
||||
type: 'workflow_status',
|
||||
workflowStatus: expect.objectContaining({
|
||||
phase: 'provider_fallback_rejected',
|
||||
message: 'Provider fallback metadata received; selected route unchanged.',
|
||||
message: 'Provider route-switch metadata ignored; selected route unchanged.',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
@@ -1454,7 +1454,7 @@ describe('useChat', () => {
|
||||
expect(assistant.workflowStatus).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'provider_fallback_rejected',
|
||||
message: 'Provider fallback metadata received; selected route unchanged.',
|
||||
message: 'Provider route-switch metadata ignored; selected route unchanged.',
|
||||
}),
|
||||
);
|
||||
expect(assistant.streamEvents).not.toEqual(
|
||||
|
||||
@@ -986,7 +986,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
if (phase === 'provider_fallback') {
|
||||
return {
|
||||
phase: 'provider_fallback_rejected',
|
||||
message: 'Provider fallback metadata received; selected route unchanged.',
|
||||
message: 'Provider route-switch metadata ignored; selected route unchanged.',
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -434,8 +434,10 @@ const findProviderReadinessAlternative = (args: {
|
||||
sortedCandidates[0];
|
||||
if (!candidate) return null;
|
||||
|
||||
const candidateKey = normalizeComparableModelKey(candidate.model.id);
|
||||
return {
|
||||
id: candidate.model.id,
|
||||
kind: candidateKey === selectedKey ? 'same-model-route' : 'alternate-model-route',
|
||||
label: formatAIModelRouteLabel(candidate.model),
|
||||
provider: candidate.provider,
|
||||
providerLabel: getAIProviderDisplayName(candidate.provider),
|
||||
@@ -2228,6 +2230,14 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
switchToModelRoute(alternative.id);
|
||||
};
|
||||
|
||||
const providerReadinessAlternativeButtonLabel = () => {
|
||||
const alternative = providerReadinessAlternative();
|
||||
if (!alternative) return '';
|
||||
return alternative.kind === 'same-model-route'
|
||||
? `Use ${alternative.providerLabel} route`
|
||||
: `Use ${alternative.providerLabel} model route`;
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const sessionId = chat.sessionId();
|
||||
const storedModel = getStoredModel(sessionId);
|
||||
@@ -4209,10 +4219,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
type="button"
|
||||
onClick={switchToProviderReadinessAlternative}
|
||||
class="inline-flex max-w-[11rem] items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
|
||||
aria-label={`Use ${alternative().providerLabel} provider route`}
|
||||
aria-label={providerReadinessAlternativeButtonLabel()}
|
||||
title={alternative().label}
|
||||
>
|
||||
<span class="truncate">Use {alternative().providerLabel}</span>
|
||||
<span class="truncate">{providerReadinessAlternativeButtonLabel()}</span>
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -185,6 +185,7 @@ export interface ChatMessage {
|
||||
|
||||
export interface ModelRouteRecoveryOption {
|
||||
id: string;
|
||||
kind: 'same-model-route' | 'alternate-model-route';
|
||||
label: string;
|
||||
provider: string;
|
||||
providerLabel: string;
|
||||
|
||||
Reference in New Issue
Block a user