mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): show Pi reasoning effort (#1303)
This commit is contained in:
@@ -1377,6 +1377,7 @@ export function HappyComposer(props: {
|
||||
contextModel={contextModel}
|
||||
model={model}
|
||||
modelReasoningEffort={modelReasoningEffort}
|
||||
effort={effort}
|
||||
serviceTier={serviceTier}
|
||||
permissionMode={permissionMode}
|
||||
collaborationMode={collaborationMode}
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('StatusBar context details popover', () => {
|
||||
agentState={null}
|
||||
agentFlavor="codex"
|
||||
modelReasoningEffort="xhigh"
|
||||
effort="max"
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
@@ -68,6 +69,105 @@ describe('StatusBar context details popover', () => {
|
||||
expect(desktopLabel.className.split(' ')).toContain('sm:inline')
|
||||
})
|
||||
|
||||
it('keeps the Codex default reasoning label when model effort is absent', () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<StatusBar
|
||||
active
|
||||
thinking={false}
|
||||
agentState={null}
|
||||
agentFlavor="codex"
|
||||
modelReasoningEffort={null}
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('default').className.split(' ')).toContain('sm:hidden')
|
||||
expect(screen.getByText('reasoning default').className.split(' ')).toContain('sm:inline')
|
||||
})
|
||||
|
||||
it('uses Pi ordinary effort instead of model reasoning effort', () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<StatusBar
|
||||
active
|
||||
thinking={false}
|
||||
agentState={null}
|
||||
agentFlavor="pi"
|
||||
modelReasoningEffort="xhigh"
|
||||
effort="max"
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('max').className.split(' ')).toContain('sm:hidden')
|
||||
expect(screen.getByText('reasoning max').className.split(' ')).toContain('sm:inline')
|
||||
expect(screen.queryByText('reasoning xhigh')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Pi reasoning when effort is absent or blank', () => {
|
||||
const { rerender } = render(
|
||||
<I18nProvider>
|
||||
<StatusBar
|
||||
active
|
||||
thinking={false}
|
||||
agentState={null}
|
||||
agentFlavor="pi"
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.queryByText('reasoning default')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('default')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<I18nProvider>
|
||||
<StatusBar
|
||||
active
|
||||
thinking={false}
|
||||
agentState={null}
|
||||
agentFlavor="pi"
|
||||
effort=" "
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.queryByText('reasoning default')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('default')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not expose ordinary effort for Claude or unknown flavors', () => {
|
||||
const { rerender } = render(
|
||||
<I18nProvider>
|
||||
<StatusBar
|
||||
active
|
||||
thinking={false}
|
||||
agentState={null}
|
||||
agentFlavor="claude"
|
||||
effort="max"
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.queryByText('reasoning max')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('max')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<I18nProvider>
|
||||
<StatusBar
|
||||
active
|
||||
thinking={false}
|
||||
agentState={null}
|
||||
agentFlavor="unknown"
|
||||
effort="max"
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.queryByText('reasoning max')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('max')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens from the mobile-accessible context trigger and keeps the requested detail order', async () => {
|
||||
localStorage.setItem('hapi-lang', 'zh-CN')
|
||||
render(
|
||||
|
||||
@@ -12,9 +12,10 @@ import type { ConversationStatus } from '@/realtime/types'
|
||||
import type { ThreadGoal } from '@/types/api'
|
||||
import { getContextBudgetTokens } from '@/chat/modelConfig'
|
||||
import {
|
||||
formatCodexReasoningLabel,
|
||||
formatCompactCodexReasoningLabel,
|
||||
shouldShowCodexReasoningLabel
|
||||
formatReasoningLabel,
|
||||
formatCompactReasoningLabel,
|
||||
getReasoningEffortForFlavor,
|
||||
shouldShowReasoningStatusLabel
|
||||
} from '@/lib/codexStatusLabels'
|
||||
import { isFastServiceTier } from './codexFastMode'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
@@ -205,6 +206,7 @@ export function StatusBar(props: {
|
||||
contextModel?: string | null
|
||||
model?: string | null
|
||||
modelReasoningEffort?: string | null
|
||||
effort?: string | null
|
||||
serviceTier?: string | null
|
||||
permissionMode?: PermissionMode
|
||||
collaborationMode?: CodexCollaborationMode
|
||||
@@ -262,12 +264,17 @@ export function StatusBar(props: {
|
||||
const collaborationModeLabel = displayCollaborationMode
|
||||
? getCodexCollaborationModeLabel(displayCollaborationMode)
|
||||
: null
|
||||
const displaysCodexReasoning = shouldShowCodexReasoningLabel(props.agentFlavor)
|
||||
const codexReasoningLabel = displaysCodexReasoning
|
||||
? formatCodexReasoningLabel(props.modelReasoningEffort, headerMetadata.showLabels)
|
||||
const reasoningEffort = getReasoningEffortForFlavor(
|
||||
props.agentFlavor,
|
||||
props.modelReasoningEffort,
|
||||
props.effort
|
||||
)
|
||||
const displaysReasoning = shouldShowReasoningStatusLabel(props.agentFlavor, reasoningEffort)
|
||||
const reasoningLabel = displaysReasoning
|
||||
? formatReasoningLabel(reasoningEffort, headerMetadata.showLabels)
|
||||
: null
|
||||
const compactCodexReasoningLabel = displaysCodexReasoning
|
||||
? formatCompactCodexReasoningLabel(props.modelReasoningEffort)
|
||||
const compactReasoningLabel = displaysReasoning
|
||||
? formatCompactReasoningLabel(reasoningEffort)
|
||||
: null
|
||||
const codexFastMode = shouldShowCodexFastBadge(props.agentFlavor, props.serviceTier)
|
||||
const goalLabel = props.agentFlavor === 'codex' && props.threadGoal
|
||||
@@ -350,10 +357,10 @@ export function StatusBar(props: {
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 shrink-0 items-baseline gap-2">
|
||||
{codexReasoningLabel ? (
|
||||
{reasoningLabel ? (
|
||||
<span className="whitespace-nowrap text-xs text-[var(--app-hint)]">
|
||||
<span className="sm:hidden">{compactCodexReasoningLabel}</span>
|
||||
<span className="hidden sm:inline">{codexReasoningLabel}</span>
|
||||
<span className="sm:hidden">{compactReasoningLabel}</span>
|
||||
<span className="hidden sm:inline">{reasoningLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{codexFastMode ? (
|
||||
|
||||
@@ -6,7 +6,10 @@ import { I18nProvider } from '@/lib/i18n-context'
|
||||
import { ToastProvider } from '@/lib/toast-context'
|
||||
import { resolveSessionHeaderMachineLabel, SessionHeader } from './SessionHeader'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
function baseSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
@@ -79,6 +82,55 @@ describe('SessionHeader', () => {
|
||||
expect(screen.queryByText('reasoning default')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Pi ordinary effort as reasoning metadata', () => {
|
||||
renderHeader(baseSession({
|
||||
metadata: { flavor: 'pi', path: '/repo', host: 'machine' },
|
||||
modelReasoningEffort: null,
|
||||
effort: 'max'
|
||||
}))
|
||||
|
||||
expect(screen.getByTestId('session-header-reasoning')).toHaveTextContent('reasoning max')
|
||||
})
|
||||
|
||||
it('keeps model reasoning effort for Codex and hides ordinary effort for non-Pi flavors', () => {
|
||||
const { rerender } = renderHeader(baseSession({
|
||||
modelReasoningEffort: 'xhigh',
|
||||
effort: 'max'
|
||||
}))
|
||||
|
||||
expect(screen.getByTestId('session-header-reasoning')).toHaveTextContent('reasoning xhigh')
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<SessionHeader
|
||||
session={baseSession({
|
||||
metadata: { flavor: 'claude', path: '/repo', host: 'machine' },
|
||||
modelReasoningEffort: null,
|
||||
effort: 'max'
|
||||
})}
|
||||
onBack={vi.fn()}
|
||||
api={null}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('session-header-reasoning')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Pi reasoning metadata when the header reasoning setting is disabled', () => {
|
||||
localStorage.setItem('hapi-session-header-metadata', JSON.stringify({ reasoning: false }))
|
||||
renderHeader(baseSession({
|
||||
metadata: { flavor: 'pi', path: '/repo', host: 'machine' },
|
||||
effort: 'max'
|
||||
}))
|
||||
|
||||
expect(screen.queryByTestId('session-header-reasoning')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows machine label and relative last-active age in the meta row', () => {
|
||||
const fiveMinutesAgo = Date.now() - 5 * 60_000
|
||||
renderHeader(baseSession({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RenameSessionDialog } from '@/components/RenameSessionDialog'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { useScratchlistCount } from '@/lib/use-scratchlist-count'
|
||||
import { formatReopenError } from '@/lib/reopenError'
|
||||
import { formatCodexReasoningLabel, shouldShowCodexReasoningLabel } from '@/lib/codexStatusLabels'
|
||||
import { formatReasoningLabel, getReasoningEffortForFlavor } from '@/lib/codexStatusLabels'
|
||||
import { getSessionModelLabel } from '@/lib/sessionModelLabel'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
|
||||
@@ -138,9 +138,13 @@ export function SessionHeader(props: {
|
||||
const modelLabel = getSessionModelLabel(session)
|
||||
const agentFlavor = session.metadata?.flavor ?? null
|
||||
const agentLabel = agentFlavor?.trim() || null
|
||||
const reasoningEffort = session.modelReasoningEffort?.trim() || null
|
||||
const reasoningLabel = reasoningEffort && shouldShowCodexReasoningLabel(agentFlavor)
|
||||
? formatCodexReasoningLabel(reasoningEffort, headerMetadata.showLabels)
|
||||
const reasoningEffort = getReasoningEffortForFlavor(
|
||||
agentFlavor,
|
||||
session.modelReasoningEffort,
|
||||
session.effort
|
||||
)
|
||||
const reasoningLabel = reasoningEffort
|
||||
? formatReasoningLabel(reasoningEffort, headerMetadata.showLabels)
|
||||
: null
|
||||
// Match expected Fast badge semantics (#1004): only explicit service tier, no effort/model heuristics.
|
||||
const showFastBadge = agentFlavor === 'codex' && isFastServiceTier(props.serviceTier ?? session.serviceTier)
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatCodexReasoningLabel,
|
||||
formatCompactCodexReasoningLabel,
|
||||
shouldShowCodexReasoningLabel
|
||||
formatReasoningLabel,
|
||||
formatCompactReasoningLabel,
|
||||
getReasoningEffortForFlavor,
|
||||
shouldShowCodexReasoningLabel,
|
||||
shouldShowReasoningStatusLabel
|
||||
} from './codexStatusLabels'
|
||||
|
||||
describe('codexStatusLabels', () => {
|
||||
@@ -37,3 +41,30 @@ describe('codexStatusLabels', () => {
|
||||
expect(shouldShowCodexReasoningLabel(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reasoning status metadata', () => {
|
||||
it('formats Pi reasoning labels with the shared formatter', () => {
|
||||
expect(formatReasoningLabel('MAX')).toBe('reasoning max')
|
||||
expect(formatCompactReasoningLabel(' MAX ')).toBe('max')
|
||||
})
|
||||
|
||||
it('uses model reasoning effort for Codex/OpenCode and ordinary effort only for Pi', () => {
|
||||
expect(getReasoningEffortForFlavor('codex', 'xhigh', 'max')).toBe('xhigh')
|
||||
expect(getReasoningEffortForFlavor('opencode', 'high', 'max')).toBe('high')
|
||||
expect(getReasoningEffortForFlavor('pi', 'xhigh', 'max')).toBe('max')
|
||||
expect(getReasoningEffortForFlavor('claude', 'xhigh', 'max')).toBeNull()
|
||||
expect(getReasoningEffortForFlavor('grok', 'xhigh', 'max')).toBeNull()
|
||||
expect(getReasoningEffortForFlavor(null, 'xhigh', 'max')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps unset defaults for Codex/OpenCode and requires a real Pi effort', () => {
|
||||
expect(shouldShowReasoningStatusLabel('codex', null)).toBe(true)
|
||||
expect(shouldShowReasoningStatusLabel('opencode', null)).toBe(true)
|
||||
expect(shouldShowReasoningStatusLabel('pi', 'max')).toBe(true)
|
||||
expect(shouldShowReasoningStatusLabel('pi', null)).toBe(false)
|
||||
expect(shouldShowReasoningStatusLabel('pi', ' ')).toBe(false)
|
||||
expect(shouldShowReasoningStatusLabel('claude', 'max')).toBe(false)
|
||||
expect(shouldShowReasoningStatusLabel('grok', 'max')).toBe(false)
|
||||
expect(shouldShowReasoningStatusLabel(null, 'max')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
/** Labels shared by SessionHeader and composer StatusBar for Codex/OpenCode. */
|
||||
/** Labels shared by SessionHeader and composer StatusBar for supported reasoning providers. */
|
||||
|
||||
export function formatCompactCodexReasoningLabel(effort?: string | null): string {
|
||||
export function formatCompactReasoningLabel(effort?: string | null): string {
|
||||
const normalized = effort?.trim().toLowerCase()
|
||||
if (!normalized || normalized === 'default') return 'default'
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function formatCodexReasoningLabel(effort?: string | null, showLabel = true): string {
|
||||
const value = formatCompactCodexReasoningLabel(effort)
|
||||
export function formatReasoningLabel(effort?: string | null, showLabel = true): string {
|
||||
const value = formatCompactReasoningLabel(effort)
|
||||
return showLabel ? `reasoning ${value}` : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Status metadata comes from different session fields by agent protocol:
|
||||
* Codex/OpenCode publish model reasoning effort, while Pi publishes effort.
|
||||
*/
|
||||
export function getReasoningEffortForFlavor(
|
||||
agentFlavor: string | null | undefined,
|
||||
modelReasoningEffort?: string | null,
|
||||
effort?: string | null
|
||||
): string | null {
|
||||
if (agentFlavor === 'codex' || agentFlavor === 'opencode') {
|
||||
return modelReasoningEffort?.trim() || null
|
||||
}
|
||||
if (agentFlavor === 'pi') {
|
||||
return effort?.trim() || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function shouldShowReasoningStatusLabel(
|
||||
agentFlavor: string | null | undefined,
|
||||
reasoningEffort?: string | null
|
||||
): boolean {
|
||||
// Codex/OpenCode retain their existing explicit default label when unset.
|
||||
if (agentFlavor === 'codex' || agentFlavor === 'opencode') return true
|
||||
// Pi has no meaningful default for non-reasoning models; only show a real level.
|
||||
return agentFlavor === 'pi' && Boolean(reasoningEffort?.trim())
|
||||
}
|
||||
|
||||
/** Codex-only aliases retained for tool-card and thread call sites. */
|
||||
export const formatCompactCodexReasoningLabel = formatCompactReasoningLabel
|
||||
export const formatCodexReasoningLabel = formatReasoningLabel
|
||||
|
||||
export function shouldShowCodexReasoningLabel(agentFlavor: string | null | undefined): boolean {
|
||||
return agentFlavor === 'codex' || agentFlavor === 'opencode'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user