Skip to content

feat: add Codex fast service tier toggle#780

Open
NightWatcher314 wants to merge 7 commits into
tiann:mainfrom
NightWatcher314:codex-fast-service-tier
Open

feat: add Codex fast service tier toggle#780
NightWatcher314 wants to merge 7 commits into
tiann:mainfrom
NightWatcher314:codex-fast-service-tier

Conversation

@NightWatcher314

Copy link
Copy Markdown
Contributor

Summary

  • add persisted serviceTier session config for Codex and propagate it through spawn/resume, keepalive, RPC config updates, and Codex app-server thread/turn params
  • expose Codex model service tiers from model/list and add a web Fast toggle for remote Codex sessions
  • update status bar to reflect the actual Codex service tier instead of inferring “fast” from model/reasoning heuristics

Tests

  • bun typecheck
  • bun test hub/src/web/routes/sessions.test.ts hub/src/sync/sessionModel.test.ts
  • cd cli && bun test src/codex/utils/appServerConfig.test.ts

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Local Codex launches never receive the selected service tier — --service-tier is consumed into options.serviceTier, but the local launcher only forwards Codex CLI argv/model-reasoning config to the spawned codex process. Local hapi codex --service-tier priority and 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 handleModelChange only updates model; if the prior serviceTier is still priority and the new model has no matching tier, the toggle becomes disabled while StatusBar still shows Fast and subsequent turns still send serviceTier: 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

Comment thread cli/src/commands/codex.ts
if (!tier) {
throw new Error('Missing --service-tier value')
}
options.serviceTier = tier

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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);
}

Comment thread web/src/components/SessionChat.tsx Outdated
if (agentFlavor !== 'codex') {
return null
}
const selectedModel = props.session.model ?? codexModelsState.models.find((model) => model.isDefault)?.id ?? null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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])

@NightWatcher314

Copy link
Copy Markdown
Contributor Author

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 existing serviceTier is 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

Comment thread web/src/components/SessionChat.tsx Outdated
if (agentFlavor !== 'codex') {
return null
}
const selectedModel = props.session.model ?? codexModelsState.models.find((model) => model.isDefault)?.id ?? null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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])

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread cli/src/codex/loop.ts Outdated
model: opts.model,
serviceTier: opts.serviceTier,
modelReasoningEffort: opts.modelReasoningEffort,
collaborationMode: opts.collaborationMode ?? 'default'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
})

@NightWatcher314 NightWatcher314 force-pushed the codex-fast-service-tier branch from d8626ed to 4550340 Compare June 8, 2026 01:51

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [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 the new added line and bun typecheck cannot 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; bun is not installed in this runner)

HAPI Bot

}
session.effort = payload.effort
}
if (payload.serviceTier !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'serviceTier', t)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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'

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Mark service-tier web updates as fresh runtime config — handleSessionAlive already rejects stale keepalives for serviceTier, but applySessionConfig never records the update time for this new branch. After a web Fast toggle, the next older session-alive packet 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Codex model/list shape exposing serviceTiers with a tier named fast.

Testing

  • Not run (automation; PR content not executed per security policy)

HAPI Bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant