diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx
index 4ae6b84c..ab08947a 100644
--- a/web/src/components/AssistantChat/HappyComposer.tsx
+++ b/web/src/components/AssistantChat/HappyComposer.tsx
@@ -1377,6 +1377,7 @@ export function HappyComposer(props: {
contextModel={contextModel}
model={model}
modelReasoningEffort={modelReasoningEffort}
+ effort={effort}
serviceTier={serviceTier}
permissionMode={permissionMode}
collaborationMode={collaborationMode}
diff --git a/web/src/components/AssistantChat/StatusBar.popover.test.tsx b/web/src/components/AssistantChat/StatusBar.popover.test.tsx
index ad7ca15b..4e0acea6 100644
--- a/web/src/components/AssistantChat/StatusBar.popover.test.tsx
+++ b/web/src/components/AssistantChat/StatusBar.popover.test.tsx
@@ -58,6 +58,7 @@ describe('StatusBar context details popover', () => {
agentState={null}
agentFlavor="codex"
modelReasoningEffort="xhigh"
+ effort="max"
/>
)
@@ -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(
+
+
+
+ )
+
+ 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(
+
+
+
+ )
+
+ 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(
+
+
+
+ )
+
+ expect(screen.queryByText('reasoning default')).not.toBeInTheDocument()
+ expect(screen.queryByText('default')).not.toBeInTheDocument()
+
+ rerender(
+
+
+
+ )
+
+ 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(
+
+
+
+ )
+
+ expect(screen.queryByText('reasoning max')).not.toBeInTheDocument()
+ expect(screen.queryByText('max')).not.toBeInTheDocument()
+
+ rerender(
+
+
+
+ )
+
+ 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(
diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx
index 86371610..8010e907 100644
--- a/web/src/components/AssistantChat/StatusBar.tsx
+++ b/web/src/components/AssistantChat/StatusBar.tsx
@@ -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: {
- {codexReasoningLabel ? (
+ {reasoningLabel ? (
- {compactCodexReasoningLabel}
- {codexReasoningLabel}
+ {compactReasoningLabel}
+ {reasoningLabel}
) : null}
{codexFastMode ? (
diff --git a/web/src/components/SessionHeader.test.tsx b/web/src/components/SessionHeader.test.tsx
index 4a57735d..5c4ad85c 100644
--- a/web/src/components/SessionHeader.test.tsx
+++ b/web/src/components/SessionHeader.test.tsx
@@ -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 {
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(
+
+
+
+
+
+
+
+ )
+
+ 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({
diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx
index 8584e20d..071c4703 100644
--- a/web/src/components/SessionHeader.tsx
+++ b/web/src/components/SessionHeader.tsx
@@ -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)
diff --git a/web/src/lib/codexStatusLabels.test.ts b/web/src/lib/codexStatusLabels.test.ts
index 4358bdcf..7b165d09 100644
--- a/web/src/lib/codexStatusLabels.test.ts
+++ b/web/src/lib/codexStatusLabels.test.ts
@@ -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)
+ })
+})
diff --git a/web/src/lib/codexStatusLabels.ts b/web/src/lib/codexStatusLabels.ts
index 4702cc89..ae070cd3 100644
--- a/web/src/lib/codexStatusLabels.ts
+++ b/web/src/lib/codexStatusLabels.ts
@@ -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'
}