feat: add Codex fast service tier toggle#780
Conversation
There was a problem hiding this comment.
Findings
- [Major] Local Codex launches never receive the selected service tier —
--service-tieris consumed intooptions.serviceTier, but the local launcher only forwards Codex CLI argv/model-reasoning config to the spawnedcodexprocess. Localhapi codex --service-tier priorityand local resume of a persisted fast tier will show/persist Fast in HAPI while the actual local Codex process runs without that tier. Evidence:cli/src/commands/codex.ts:79.
Suggested fix:// cli/src/codex/codexLocalLauncher.ts await codexLocal({ path: session.path, sessionId: resumeSessionId, modelReasoningEffort: (session.getModelReasoningEffort() ?? undefined) as ReasoningEffort | undefined, serviceTier: session.getServiceTier() ?? undefined, onSessionFound: handleSessionFound, abort: abortSignal, codexArgs, mcpServers, sessionHook: { port: hookServer.port, token: hookServer.token } }) // cli/src/codex/codexLocal.ts serviceTier?: string | null; if (opts.serviceTier) { args.push('--service-tier', opts.serviceTier); }
- [Major] Changing Codex models can leave an unsupported Fast tier stuck on the session — the new tier lookup is model-specific, but
handleModelChangeonly updatesmodel; if the priorserviceTieris stillpriorityand the new model has no matching tier, the toggle becomes disabled while StatusBar still shows Fast and subsequent turns still sendserviceTier: priority. Evidence:web/src/components/SessionChat.tsx:162.
Suggested fix:const supportsServiceTier = useCallback((modelId: string | null, tierId: string | null) => { if (!tierId) return true const selectedModel = modelId ?? codexModelsState.models.find((model) => model.isDefault)?.id ?? null const modelSummary = codexModelsState.models.find((codexModel) => codexModel.id === selectedModel) return (modelSummary?.serviceTiers ?? []).some((tier) => tier.id === tierId) }, [codexModelsState.models]) const handleModelChange = useCallback(async (model: string | null) => { try { await setModel(model) if (agentFlavor === 'codex' && !supportsServiceTier(model, props.session.serviceTier)) { await setServiceTier(null) } haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') console.error('Failed to set model:', e) } }, [setModel, setServiceTier, agentFlavor, supportsServiceTier, props.session.serviceTier, props.onRefresh, haptic])
Summary
Review mode: initial
Two issues found: local Codex service-tier propagation is incomplete, and model changes can leave an invalid/stale Fast tier active. No questions.
Testing
Not run (automation)
HAPI Bot
| if (!tier) { | ||
| throw new Error('Missing --service-tier value') | ||
| } | ||
| options.serviceTier = tier |
There was a problem hiding this comment.
[MAJOR] Local Codex launches never receive the selected service tier. This parser consumes --service-tier into HAPI state, but the local launcher spawns codex from codexArgs/explicit local options; unlike --model, this value is not forwarded to the actual local Codex process. That makes hapi codex --service-tier priority and local resume of a persisted tier show/persist Fast in HAPI while local Codex runs with the default tier.
Suggested fix:
// cli/src/codex/codexLocalLauncher.ts
await codexLocal({
path: session.path,
sessionId: resumeSessionId,
modelReasoningEffort: (session.getModelReasoningEffort() ?? undefined) as ReasoningEffort | undefined,
serviceTier: session.getServiceTier() ?? undefined,
onSessionFound: handleSessionFound,
abort: abortSignal,
codexArgs,
mcpServers,
sessionHook: { port: hookServer.port, token: hookServer.token }
})
// cli/src/codex/codexLocal.ts
serviceTier?: string | null;
if (opts.serviceTier) {
args.push('--service-tier', opts.serviceTier);
}| if (agentFlavor !== 'codex') { | ||
| return null | ||
| } | ||
| const selectedModel = props.session.model ?? codexModelsState.models.find((model) => model.isDefault)?.id ?? null |
There was a problem hiding this comment.
[MAJOR] This derives Fast support from the selected model, but changing the model never clears an already-persisted serviceTier. If a user enables Fast on one model, then switches to a model without that tier, the toggle becomes disabled while props.session.serviceTier remains priority; StatusBar still shows Fast and the next Codex turn still sends serviceTier: priority for an unsupported model.
Suggested fix:
const supportsServiceTier = useCallback((modelId: string | null, tierId: string | null) => {
if (!tierId) return true
const selectedModel = modelId ?? codexModelsState.models.find((model) => model.isDefault)?.id ?? null
const modelSummary = codexModelsState.models.find((codexModel) => codexModel.id === selectedModel)
return (modelSummary?.serviceTiers ?? []).some((tier) => tier.id === tierId)
}, [codexModelsState.models])
const handleModelChange = useCallback(async (model: string | null) => {
try {
await setModel(model)
if (agentFlavor === 'codex' && !supportsServiceTier(model, props.session.serviceTier)) {
await setServiceTier(null)
}
haptic.notification('success')
props.onRefresh()
} catch (e) {
haptic.notification('error')
console.error('Failed to set model:', e)
}
}, [setModel, setServiceTier, agentFlavor, supportsServiceTier, props.session.serviceTier, props.onRefresh, haptic])|
Thanks for the review. I pushed 490065b to fix the local launch/test coverage issue: serviceTier now propagates through codexLocalLauncher/codexLocal and the affected CLI mocks are covered by tests.\n\nFor the model-switch behavior: the current behavior is intentional for this first pass. HAPI stores serviceTier as user intent rather than an effective runtime tier; if a selected model does not advertise Fast, the UI disables the Fast control while still showing the persisted requested tier, and the app-server remains the final validator on the next turn. I can change this to clear unsupported tiers on model switch if maintainers prefer that policy. |
There was a problem hiding this comment.
Findings
- [Major] Changing Codex models can leave an unsupported Fast tier active — the Fast tier lookup is now model-specific, but the model-change path still only persists
model; an existingserviceTieris not cleared when the newly selected model has no matching Fast tier. After enabling Fast on one model and switching to an unsupported model, the toggle becomes disabled while the session still sends the stale tier on future Codex turns. Evidence:web/src/components/SessionChat.tsx:162.
Suggested fix:const supportsServiceTier = useCallback((modelId: string | null, tierId: string | null) => { if (!tierId) return true const selectedModel = modelId ?? codexModelsState.models.find((model) => model.isDefault)?.id ?? null const modelSummary = codexModelsState.models.find((codexModel) => codexModel.id === selectedModel) return (modelSummary?.serviceTiers ?? []).some((tier) => tier.id === tierId) }, [codexModelsState.models]) const handleModelChange = useCallback(async (model: string | null) => { try { await setModel(model) if (agentFlavor === 'codex' && !supportsServiceTier(model, props.session.serviceTier)) { await setServiceTier(null) } haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') console.error('Failed to set model:', e) } }, [setModel, setServiceTier, agentFlavor, supportsServiceTier, props.session.serviceTier, props.onRefresh, haptic])
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The local Codex launcher propagation issue from the previous review was addressed. The stale service-tier-on-model-change issue remains.
Testing
- Not run (automation)
HAPI Bot
| if (agentFlavor !== 'codex') { | ||
| return null | ||
| } | ||
| const selectedModel = props.session.model ?? codexModelsState.models.find((model) => model.isDefault)?.id ?? null |
There was a problem hiding this comment.
[MAJOR] Changing Codex models can leave an unsupported Fast tier active. This Fast tier lookup is model-specific, but the model-change handler still only persists model; it does not clear an existing serviceTier when the newly selected model has no matching Fast tier. After enabling Fast on one model and switching to an unsupported model, the toggle becomes disabled while the session still sends the stale tier on future Codex turns.
Suggested fix:
const supportsServiceTier = useCallback((modelId: string | null, tierId: string | null) => {
if (!tierId) return true
const selectedModel = modelId ?? codexModelsState.models.find((model) => model.isDefault)?.id ?? null
const modelSummary = codexModelsState.models.find((codexModel) => codexModel.id === selectedModel)
return (modelSummary?.serviceTiers ?? []).some((tier) => tier.id === tierId)
}, [codexModelsState.models])
const handleModelChange = useCallback(async (model: string | null) => {
try {
await setModel(model)
if (agentFlavor === 'codex' && !supportsServiceTier(model, props.session.serviceTier)) {
await setServiceTier(null)
}
haptic.notification('success')
props.onRefresh()
} catch (e) {
haptic.notification('error')
console.error('Failed to set model:', e)
}
}, [setModel, setServiceTier, agentFlavor, supportsServiceTier, props.session.serviceTier, props.onRefresh, haptic])There was a problem hiding this comment.
Findings
- [Major] Codex import no longer replays existing transcript history. Evidence:
cli/src/codex/loop.ts:62.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The previous stale service-tier-on-model-change issue was addressed. One new regression remains: the PR removes the transcript replay flag used when attaching a new HAPI session to an existing Codex thread, and also removes the tests that covered that import path.
Testing
- Not run (automation)
HAPI Bot
| model: opts.model, | ||
| serviceTier: opts.serviceTier, | ||
| modelReasoningEffort: opts.modelReasoningEffort, | ||
| collaborationMode: opts.collaborationMode ?? 'default' |
There was a problem hiding this comment.
[MAJOR] Codex import no longer replays existing transcript history. The PR removes replayTranscriptHistoryOnStart from runCodex/loop/CodexSession and stops passing replayExistingHistory into createCodexSessionScanner; the scanner now starts in tail-only mode even for hapi codex resume <threadId> when there is no existing HAPI session. That means importing an existing local Codex thread creates a HAPI session that only shows new output, dropping the conversation history that was previously backfilled. The deleted codexLocalLauncher/runCodex tests covered exactly this path.
Suggested fix:
// runCodex.ts
const replayTranscriptHistoryOnStart = Boolean(opts.resumeSessionId && !opts.existingSessionId)
await loop({
...,
resumeSessionId: opts.resumeSessionId,
replayTranscriptHistoryOnStart,
...
})
// loop.ts / session.ts
replayTranscriptHistoryOnStart?: boolean
// pass it into CodexSession and keep it as a readonly property
// codexLocalLauncher.ts
const createdScanner = await createCodexSessionScanner({
transcriptPath,
replayExistingHistory: session.replayTranscriptHistoryOnStart,
onSessionId,
onEvent
})d8626ed to
4550340
Compare
There was a problem hiding this comment.
Findings
- [Blocker] Add
serviceTierto the runtime config key union —handleSessionAlivenow callsisStaleRuntimeKeepAlive(session.id, 'serviceTier', t), butRuntimeConfigKeystill excludesserviceTier, sohubstrict TypeScript rejects the new added line andbun typecheckcannot pass. Evidence:hub/src/sync/sessionCache.ts:235.
Suggested fix:type RuntimeConfigKey = 'permissionMode' | 'model' | 'modelReasoningEffort' | 'effort' | 'serviceTier' | 'collaborationMode'
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Current head still has a blocking hub type error in the service-tier keepalive path. Residual testing gap: no test currently exercises the stale keepalive guard for service-tier updates.
Testing
- Not run (automation;
bunis not installed in this runner)
HAPI Bot
| } | ||
| session.effort = payload.effort | ||
| } | ||
| if (payload.serviceTier !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'serviceTier', t)) { |
There was a problem hiding this comment.
[BLOCKER] Add serviceTier to the runtime config key union. handleSessionAlive now calls isStaleRuntimeKeepAlive(session.id, 'serviceTier', t), but RuntimeConfigKey still excludes serviceTier, so hub strict TypeScript rejects this added line and bun typecheck cannot pass.
Suggested fix:
type RuntimeConfigKey = 'permissionMode' | 'model' | 'modelReasoningEffort' | 'effort' | 'serviceTier' | 'collaborationMode'There was a problem hiding this comment.
Findings
- [Major] Mark service-tier web updates as fresh runtime config —
handleSessionAlivealready rejects stale keepalives forserviceTier, butapplySessionConfignever records the update time for this new branch. After a web Fast toggle, the next oldersession-alivepacket can pass the stale guard and write the previous tier back to cache/SQLite, making the toggle flicker or silently revert. Evidence:hub/src/sync/sessionCache.ts:484.
Suggested fix:session.serviceTier = config.serviceTier this.markRuntimeConfigUpdated(sessionId, 'serviceTier', appliedAt)
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The previous typecheck blocker is fixed, but the new service-tier runtime config path still misses the stale-keepalive protection used by the adjacent runtime fields.
Testing
- Not run (automation; PR content not executed per security policy)
HAPI Bot
| throw new Error('Failed to update session service tier') | ||
| } | ||
| } | ||
| session.serviceTier = config.serviceTier |
There was a problem hiding this comment.
[MAJOR] Mark service-tier web updates as fresh runtime config. handleSessionAlive already checks isStaleRuntimeKeepAlive(session.id, 'serviceTier', t), but this branch never calls markRuntimeConfigUpdated, unlike the adjacent model/effort/collaboration branches. After a web Fast toggle, an older volatile keepalive can still pass the guard and write the previous tier back to cache/SQLite.
Suggested fix:
session.serviceTier = config.serviceTier
this.markRuntimeConfigUpdated(sessionId, 'serviceTier', appliedAt)There was a problem hiding this comment.
Findings
- No issues found.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The prior service-tier stale keepalive issue is fixed at
hub/src/sync/sessionCache.ts:485. I did not find remaining high-confidence correctness/security regressions in the current diff. Residual risk: I did not execute PR code; web behavior for the Fast toggle depends on the live Codexmodel/listshape exposingserviceTierswith a tier namedfast.
Testing
- Not run (automation; PR content not executed per security policy)
HAPI Bot
Summary
serviceTiersession config for Codex and propagate it through spawn/resume, keepalive, RPC config updates, and Codex app-server thread/turn paramsmodel/listand add a web Fast toggle for remote Codex sessionsTests
bun typecheckbun test hub/src/web/routes/sessions.test.ts hub/src/sync/sessionModel.test.tscd cli && bun test src/codex/utils/appServerConfig.test.ts