Skip to content

feat(claude): discover models and context window from the CLI - #1605

Open
junmo-kim wants to merge 34 commits into
tiann:mainfrom
junmo-kim:feat/claude-model-catalog
Open

feat(claude): discover models and context window from the CLI#1605
junmo-kim wants to merge 34 commits into
tiann:mainfrom
junmo-kim:feat/claude-model-catalog

Conversation

@junmo-kim

Copy link
Copy Markdown
Contributor

Claude is the last launcher whose model list is a compile-time constant. agy, codex, copilot, cursor, grok, opencode and pi all ask their CLI what it can run; this brings claude in line with them, and while the catalog is available it also lets the context-window indicator use a measured value instead of a guess.

This is larger than I'd like for one PR because both halves read the same catalog and splitting them would leave the picker offering a model whose window the status bar still guesses. It's arranged as three commits that each stand on their own and pass the suites independently, so it can be reviewed one at a time: the CLI-side catalog path, then the pickers that consume it, then the context-window seed.

Problem

shared/src/models.ts lists six Claude presets, three of them bare/[1m] pairs. Measuring each alias against claude 2.1.233 shows the pairs are the same model:

--model reported context window
sonnet / sonnet[1m] 967,000 / 967,000
opus / opus[1m] 1,000,000 / 1,000,000
fable / fable[1m] 1,000,000 / 1,000,000
haiku 200,000

So the picker offers three choices that do nothing, and haiku — which the CLI does offer — is missing entirely. The list also can't follow account or organisation policy, or a new model generation, without a release.

The same [1m] suffix is used as the signal for the context-window budget, in sdkToLogConverter's turn-1 seed and in web/src/chat/modelConfig.ts's fallback. Since the suffix doesn't track the window, a session on bare sonnet is measured against 200,000 rather than its real 967,000, which inflates the percentage the status bar shows for the whole first turn. The existing SDK-result path corrects this once a turn completes, and a recent fix already special-cased Fable's bare id in the local-mode fallback for the same reason; this generalises that.

Solution

Catalog. cli/src/modules/common/claudeModels.ts sends a list_models control request to a headless claude -p and returns the rows verbatim, following the existing *Models.ts shape: a 60s TTL cache with single-flight coalescing, keyed by cwd, never caching failures. No prompt is sent, so the probe costs no tokens. It's reached through a machine RPC and GET /machines/:id/claude-models, matching the sibling routes.

Picker. Both surfaces — the New Session form and the composer — render the catalog when it's available and fall back to constants when it isn't. The constants keep every alias they recognise, including the [1m] forms that existing sessions may have stored, but the list offered to the picker no longer synthesises the duplicate pairs. Wire values pass through untouched: opus[1m] is a real catalog value and default still maps onto the existing null sentinel, so nothing changes in what gets stored or passed to --model.

Claude model picker showing the live catalog

Effort. Rows carry supportedEffortLevels, so the effort control now reflects what the selected model actually accepts; haiku reports none and the control is hidden for it. Whether a row's absent field means "unsupported" is decided per catalog rather than per row: if no row in a response carries the field, the CLI isn't reporting it and the static list is kept.

Context window. A get_context_usage control request on the session's existing process seeds the real window at init, so turn 1 starts from the measured number instead of a suffix guess. The authoritative result.modelUsage path is unchanged and still wins; the seed only fills the gap before the first result lands, and the cache key is captured once at request time so a mid-flight model switch can't land the measurement on another entry. The local-mode fallback now keys on the model family after normalising the suffix away.

Tests

Unit tests cover the probe (parsing, empty and failed responses, timeout, cache hits avoiding a spawn, single-flight coalescing, request-id matching, and a write to a closed stdin not escaping), the seed ordering including a mid-flight model switch and the existing anti-flicker behaviour, the catalog-to-picker mapping including stored resolved ids and legacy [1m] aliases, effort resolution for both the reporting and non-reporting catalog shapes, and the new hub route.

Manually verified end to end against a real claude 2.1.233 on an isolated hub: the endpoint returns the live catalog in ~0.9s cold and 0ms cached, five concurrent requests spawn one probe, the picker shows the catalog with no duplicate pairs, the effort control hides for haiku, and a bare sonnet session reports 52k / 967k from its first turn.

Notes

Model discovery is scoped by cwd rather than to a running session, matching how the New Session form needs it before a session exists. supportsFastMode is parsed by the CLI but not forwarded, since nothing reads it yet.

AI disclosure per CONTRIBUTING: written with Claude Code (Opus 5 for design and review, Sonnet for implementation).

Adds a path that queries the claude CLI's list_models control request to read its model catalog, following the shape of the other launchers' *Models.ts modules with a 60-second TTL cache and single-flight coalescing keyed by cwd, and never caching a failed or empty response. No prompt is sent, so discovery carries no token cost. The catalog is exposed through a machine RPC and a GET /machines/:id/claude-models route on the hub. Nothing consumes it yet.
…talog

Both the New Session form and the composer now prefer the live catalog when it's available and fall back to the constants when it isn't. The constants keep serving as a recognized-alias map for whatever a session may already have stored, including legacy [1m] suffixes, while the list the pickers actually offer no longer synthesizes bare/[1m] duplicate pairs. Wire values pass through unchanged -- opus[1m] is a real catalog value, and default maps onto the existing null sentinel the same way it always has.

The effort control reflects each row's supportedEffortLevels. Since a single row's absence of that field can't say on its own whether the model lacks effort support or the running CLI simply doesn't report the field, that judgment is made once across the whole catalog rather than per row: if any row in the response carries the field, an absent field on another row is a real signal; if none do, nothing has been confirmed and the picker falls back to the static list.
Sends get_context_usage to a session's already-running process at init time to seed the turn-1 context window from an actual measurement rather than a guess. The authoritative result.modelUsage path stays exactly as it was and continues to take priority once a result arrives; this only fills the gap before the first one lands. The cache key used to store that seed is computed once at request time so a model switch in flight can't land the measurement on the wrong entry. The local-mode fallback normalizes a stored model id's suffix before judging its family, so the window estimate follows the model family rather than the suffix.

@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] Isolate model discovery from project settings and startup hooks — the new claude -p probe runs in the selected repository and only disables project MCP configuration. Project .claude/settings*.json can still register SessionStart commands, so merely loading the picker can execute repository-defined code before the user starts a session. Evidence cli/src/modules/common/claudeModels.ts:82.
    Suggested fix:
    const args = [
        "-p",
        "--input-format", "stream-json",
        "--output-format", "stream-json",
        "--verbose",
        "--strict-mcp-config",
        "--setting-sources", "user"
    ]
  • [Major] Do not skip the live catalog for controlled Claude sessions — Claude model and effort routes still permit changes while controlledByUser is true, but this gate forces those sessions onto the static list. That list now offers haiku while still exposing every static effort level, and the reconciliation effect also exits for controlled sessions, allowing an invalid haiku + high configuration. Evidence web/src/components/SessionChat.tsx:855.
    Suggested fix:
    enabled: agentFlavor === "claude" && props.session.active
    // Also remove controlledByUser from the Claude effort reconciliation guard.
  • [Major] Apply model and required effort reset in one config update — the post-render effect clears an unsupported effort only after the model RPC and refresh complete. A prompt sent in that gap, or a failed second RPC, leaves the CLI with the invalid combination the new catalog is intended to prevent. Evidence web/src/components/SessionChat.tsx:1484.
    Suggested fix:
    await api.setSessionConfig(sessionId, {
        model: nextModel,
        ...(mustClearClaudeEffort ? { effort: null } : {})
    })

Summary

  • Review mode: initial
  • Three Major issues found: repository hook execution during discovery, missing live effort gating for controlled Claude sessions, and non-atomic model/effort reconciliation.

Testing

  • Not run (review automation; PR code was not executed per security policy).
  • Missing coverage for project-setting isolation, controlledByUser Claude catalog behavior, and a model switch that must clear effort before the next turn.

HAPI Bot

Comment thread cli/src/modules/common/claudeModels.ts Outdated
Comment thread web/src/components/SessionChat.tsx Outdated
Comment thread web/src/components/SessionChat.tsx Outdated
The list_models probe can run against an arbitrary cwd before the user
starts a session there, so it must not execute that directory's own
.claude/settings*.json -- a project SessionStart hook would otherwise
run just from opening the model picker. Restrict the probe to
user-level settings only.
…atalog

# Conflicts:
#	hub/src/sync/rpcGateway.ts
#	hub/src/sync/syncEngine.ts
#	web/src/components/NewSession/LaunchEffortSelector.tsx
#	web/src/components/NewSession/index.test.tsx
#	web/src/components/NewSession/index.tsx
#	web/src/components/SessionChat.tsx
getModelOptionsForFlavor's claude branch trusts a supplied catalog
array as-is instead of merging it onto the static presets, which
fixed a duplicate-row bug but dropped the guarantee that a null
"Default" row is always present. Add withClaudeDefaultRow and apply
it in both the options list and the Ctrl/Cmd+M cycler so a caller
that omits the row (or omits the current model) still gets one,
without reintroducing withCurrentModelOption's resolved-id dedup bug.

@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] Load the live Claude catalog for controlled sessions — model/effort mutations remain enabled, but the catalog and reconciliation are disabled, allowing unsupported combinations. Evidence web/src/components/SessionChat.tsx:968.
    Suggested fix:
    enabled: agentFlavor === 'claude' && props.session.active
    // Also remove `controlledByUser` from the reconciliation guard.
  • [Major] Clear incompatible effort in the same model update — the current effect repairs the invalid model/effort pair only after the model RPC and refresh, leaving a send/failure window. Evidence web/src/components/SessionChat.tsx:1618.
    Suggested fix:
    await api.setSessionConfig(sessionId, {
        model: nextModel,
        ...(mustClearClaudeEffort ? { effort: null } : {})
    })

Summary
Review mode: follow-up after new commits

  • The prior project-settings hook-execution finding is resolved by restricting the probe to user settings.
  • Two Major findings remain: controlled Claude sessions bypass live capability gating, and model/effort updates are not applied together.

Testing

  • Not run (automation; PR code was not executed per security policy).
  • GitHub test and integration checks pass.
  • Missing coverage: controlled Claude sessions using live model/effort capabilities; model switches that must clear effort before any subsequent turn.

HAPI Bot

Comment thread web/src/components/SessionChat.tsx Outdated
Comment thread web/src/components/SessionChat.tsx Outdated
…ically

The hub's model/effort routes for Claude sessions never gated on
controlledByUser the way codex/cursor/grok do, so the composer's catalog
query and effort reconciliation excluding controlled sessions left them on
a static preset list with no reconciliation -- free to submit model/effort
combinations the route itself keeps consistent for every other session.
Drive both off the same session set the routes actually accept changes
for instead.

Also fold an effort clear into the same model-change request when the
target model drops support for the currently pinned effort, so a prompt
sent between a model RPC and a separate effort-clear RPC can't catch the
CLI with an incompatible pair, and a failed second request can't leave the
stale effort stranded.

@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] Preserve Haiku's no-effort capability in fallback mode — the new fallback catalog offers Haiku, but both effort pickers expose every static effort whenever live discovery is unavailable or still loading. This permits haiku + high before the probe completes, after a probe failure, or with an older CLI, even though this PR establishes that Haiku supports no effort levels. Evidence shared/src/models.ts:31, web/src/components/NewSession/EffortField.tsx:72, web/src/components/AssistantChat/HappyComposer.tsx:228.
    Suggested fix:
    export const CLAUDE_FALLBACK_EFFORT_LEVELS = {
        haiku: []
    } satisfies Partial<Record<ClaudeModelPreset, ClaudeEffortLevel[]>>
    
    // In both fallback selector paths:
    const levels = CLAUDE_FALLBACK_EFFORT_LEVELS[selectedModel] ?? CLAUDE_EFFORT_LEVELS

Summary
Review mode: follow-up after new commits

  • The two findings from the prior HAPI Bot review no longer reproduce at the fresh head.
  • One Major fallback-path regression remains: Haiku can still be combined with an unsupported effort while capability discovery is unavailable or pending.

Testing

  • Not run (automation; PR code was not executed per security policy).
  • GitHub test and integration checks pass.
  • Missing coverage: fallback/loading/probe-error flows must keep Haiku at Auto-only in both New Session and the session composer.

HAPI Bot

Comment thread shared/src/models.ts Outdated
CLAUDE_MODEL_FALLBACK_OPTIONS is a hand-maintained list -- we hardcoded
haiku's identity there and this PR established that haiku doesn't
support --effort, so leaving its capability as "unconfirmed" once the
live catalog is unavailable was self-contradictory. Add
supportedEffortLevels to each fallback entry (haiku: [], the rest: the
full level list) and extend resolveClaudeSupportedEffortLevels to
consult that list when the live catalog can't confirm a selection, so
NewSession/SessionChat's effort pickers gate to Auto-only for haiku
even before a live catalog loads.

@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] Static fallback does not clear an incompatible Claude effort; see inline comment at web/src/components/AssistantChat/claudeModelOptions.ts:166.
  • [Major] Combined model/effort requests bypass effort capability validation; see inline comment at hub/src/web/routes/sessions.ts:663.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. The prior Haiku fallback UI finding is only partially resolved: fallback transitions can still persist an invalid effort, and the widened model endpoint accepts effort for unsupported flavors.

Testing

  • Not run (automation); static review only, per untrusted-PR execution restriction.

HAPI Bot

Comment thread web/src/components/AssistantChat/claudeModelOptions.ts Outdated
Comment thread hub/src/web/routes/sessions.ts
resolveClaudeSupportedEffortLevels took the catalog row as an optional
argument alongside the model identifier, so a call site could supply
the identifier without the row and silently degrade to "unconfirmed"
even when a live catalog was loaded -- resolveClaudeModelChangeEffortClear
did exactly that, leaving the fallback (no-catalog) path unable to send
an atomic effort clear on a model switch. Change the signature to take
only the model identifier and derive the row internally, so every
caller has to supply what it always has and the degraded call is no
longer expressible.

Also share /effort's two rejection guards (unsupported flavor, locally-
controlled Grok) with /model's atomic model+effort request instead of
letting the two routes' effort validation drift apart.

@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] New Session can display Default while retaining and submitting a hidden Claude model; see inline comment at web/src/components/NewSession/index.tsx:559.

Questions

  • None.

Summary
Review mode: follow-up after new commits. The two prior Major findings are addressed at the latest head. One new Major regression remains in the New Session fallback path: catalog-only/current Claude values can disappear from the rendered options without being cleared from form state.

Testing

  • Not run (automation); static review only, per untrusted-PR execution restriction. Add a NewSession test that preserves a catalog-only selection when the next cwd's catalog probe is unavailable and verifies the displayed option matches the submitted model.

HAPI Bot

Comment thread web/src/components/NewSession/index.tsx Outdated
… back

NewSession's claudeModelOptions memo skipped the shared builder whenever the
catalog was empty, falling back to ModelSelector's static MODEL_OPTIONS.claude
list. That list has no row for catalog-only values (opus[1m], fable[1m]) or
legacy aliases, so a model picked in one cwd could render as "Default" after
moving to a cwd where discovery fails while the launch form still submitted
the original model -- display and submission disagreed. Always delegating to
getClaudeComposerModelOptions keeps its "rendered options always include the
current selection" guarantee in effect in both modes.

@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

  • None.

Questions

  • None.

Summary
Review mode: follow-up after new commits. No Blocker, Major, Minor, or Nit issues found in the latest full diff. The prior New Session fallback issue at web/src/components/NewSession/index.tsx:559 is addressed by always using the canonical Claude option builder, preserving catalog-only and legacy selections when discovery becomes unavailable. Residual risk is limited to automation still in progress and the newest regression test validating display state without also asserting the submitted spawn payload.

Testing

  • Not run (automation); static review only, per the untrusted-PR execution restriction.
  • GitHub checks at review time: integration passed; test pending.
  • Suggested coverage: extend the fallback regression test to click Create and assert the same Claude model is passed to spawnSession.

HAPI Bot

The fallback list kept the declaration order it inherited from the old
preset constant (sonnet, opus, fable), while the live catalog returns
opus, fable, sonnet. The picker therefore reshuffled whenever discovery
dropped in or out, for no reason a user could see.

@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] Keep fallback context budgets conservative across Claude tiers — getContextBudgetTokens now maps bare Sonnet/Opus/Fable aliases to near-1M whenever no explicit per-session context window is available. The same PR explicitly tests tier-dependent 200k plain aliases versus 1M [1m] aliases in cli/src/claude/utils/sdkToLogConverter.test.ts:715 and cli/src/claude/utils/sdkToLogConverter.test.ts:763. On those tiers, this fallback can under-report utilization by roughly 5× and suppress the near-limit warning. Evidence web/src/chat/modelConfig.ts:127.
    Suggested fix:
    if (isClaudeModelPreset(trimmedModel) || trimmedModel.startsWith('claude-')) {
        return trimmedModel.endsWith('[1m]')
            ? LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS
            : DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS
    }
  • [Major] Preserve [1m] aliases when live discovery is unavailable — the fallback list now exposes only bare aliases, so an older CLI, transient probe failure, or unavailable machine prevents new sessions from selecting the 1M variant. This conflicts with the tier-separated behavior preserved by the new converter tests at cli/src/claude/utils/sdkToLogConverter.test.ts:715 and cli/src/claude/utils/sdkToLogConverter.test.ts:763; keeping legacy aliases only for already-stored sessions does not help a new session. Evidence shared/src/models.ts:44.
    Suggested fix:
    { value: 'opus[1m]', label: CLAUDE_MODEL_LABELS['opus[1m]'], supportedEffortLevels: [...CLAUDE_EFFORT_LEVELS] },
    { value: 'fable[1m]', label: CLAUDE_MODEL_LABELS['fable[1m]'], supportedEffortLevels: [...CLAUDE_EFFORT_LEVELS] },
    { value: 'sonnet[1m]', label: CLAUDE_MODEL_LABELS['sonnet[1m]'], supportedEffortLevels: [...CLAUDE_EFFORT_LEVELS] },

Summary

  • Review mode: follow-up after new commits. Two Major fallback regressions remain. The newest commit only reorders the static options and does not resolve the cross-tier assumptions above.

Testing

  • Not run (automation); static review only, per the untrusted-PR execution restriction.
  • GitHub checks at review time: integration passed; test pending.
  • Add regression coverage where live discovery is unavailable and plain aliases report 200k while [1m] aliases report 1M; assert both picker availability and conservative fallback budgeting.

HAPI Bot

Comment thread web/src/chat/modelConfig.ts
Comment thread shared/src/models.ts
The comments asserted that plain aliases are 200k while their "[1m]"
variants are 1M on some account tiers. Measured against claude 2.1.233
and 2.1.234 on both Pro and Max accounts, sonnet, opus and fable report
the same window either way; only haiku differs, at 200k.

The tests themselves are unchanged: keying per resolved model is what
keeps a future divergence, or an account nobody has measured, from
reading another variant's cached entry. The differing numbers are
fixtures that make a key collision visible, so they now read that way.

@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] Block sends and config changes while background effort reconciliation is in flight - the new effect starts the effort reset with void, while the composer remains enabled by disabled={props.isSending} at web/src/components/SessionChat.tsx:2094. A prompt can therefore reach Claude with the unsupported model/effort pair before the RPC lands; a concurrent model change can also be followed by this stale clear and silently lose the user's effort selection. Evidence web/src/components/SessionChat.tsx:1703.
    Suggested fix:
    const [isReconcilingClaudeEffort, setIsReconcilingClaudeEffort] = useState(false)
    
    if (!supportedLevels.includes(sessionEffort)) {
        setIsReconcilingClaudeEffort(true)
        void handleEffortChange(null, { silent: true })
            .finally(() => setIsReconcilingClaudeEffort(false))
    }
    
    // Keep sends and model/effort controls closed until the correction is applied.
    <HappyComposer disabled={props.isSending || isReconcilingClaudeEffort} />

Summary

  • Review mode: follow-up after new commits. One Major reconciliation race remains. The latest commit only clarifies test comments and does not change this runtime path.

Testing

  • Not run (automation); static review only, per the untrusted-PR execution restriction.
  • GitHub checks at review time: integration passed; test passed.
  • Add a deferred-setEffort component test that verifies send/model controls stay disabled until the reconciliation request settles.

HAPI Bot

Comment thread web/src/components/SessionChat.tsx Outdated
…ation

Both sites encode a measurement or a trade-off that is not visible from the
code alone, and reviewers have read them as oversights.

The Claude context-window family lookup strips the '[1m]' suffix rather than
treating it as a signal because the suffix carries no window information:
measured against claude 2.1.233/2.1.234 on both a Pro and a Max account,
sonnet reports 967,000 with and without it, opus and fable report 1,000,000
either way, and haiku reports 200,000. The value is also only the seed the
first get_context_usage reading replaces.

The effort reconciliation is fire-and-forget on purpose. A model change that
starts while the clear is in flight can land first, but what is lost in that
window is a level that was already invalid for the model it was stored
against. Closing the window would need a server-side compare-and-set on model
identity, which resolves to a catalog row on the client and would compare two
different representations on the server, or freezing the composer for a
correction the user never asked for.
…atalog

The create-session form is the only conflicting surface: tiann#1626 added
model-accurate thinking levels for Pi while this branch made Claude's
launch-effort levels model-dependent. Both sides only added to the shared
test mocks, so the resolution keeps both fields.

EffortField's doc comment still described Claude as static, which stopped
being true once both changes were in the same file.

@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] Serialize background effort reconciliation — the new effect deliberately starts setEffort(null) without awaiting it while HappyComposer remains enabled at web/src/components/SessionChat.tsx:2108. A prompt can therefore start with the unsupported model/effort pair before the correction lands; a concurrent switch to a supporting model can also be followed by this stale clear and silently discard the pinned effort. Evidence web/src/components/SessionChat.tsx:1717.
    Suggested fix:
    const [isReconcilingClaudeEffort, setIsReconcilingClaudeEffort] = useState(false)
    
    setIsReconcilingClaudeEffort(true)
    void handleEffortChange(null, { silent: true })
        .finally(() => setIsReconcilingClaudeEffort(false))
    
    <HappyComposer disabled={props.isSending || isReconcilingClaudeEffort} />

Summary

  • Review mode: follow-up after new commits. One Major reconciliation race remains; the new head documents accepting the race but does not serialize the conflicting interactions.

Testing

  • Not run (automation); static review only, per the untrusted-PR execution restriction.
  • GitHub checks at review time: pr-review pending.
  • Add a deferred-effort component test proving send and model controls stay disabled until reconciliation settles.

HAPI Bot

Comment thread web/src/components/SessionChat.tsx Outdated
… carry

resolvePreferredLaunchSettings clamped the saved launch model against
MODEL_OPTIONS, excluding only codex and copilot because their lists are
dynamic. Claude's list is now the live CLI catalog too, and the static
fallback lost the bare/[1m] pairs, so a saved 'sonnet[1m]' - valid on main
until this branch - was silently replaced by Default on the next open, as was
any model only the live catalog offers.

The static fallback is not an allowlist. The effort value still clamps, since
its levels come from the catalog row rather than the model id.
Both ports declare themselves ports of the shared Claude constants. Haiku is
new to CLAUDE_MODEL_LABELS, so without it a session set to Haiku renders as
the raw id in the native config sheets, and the create-session form cannot
offer it. Nothing in CI compares these catalogs, so the drift is silent.

The doc references also pointed at CLAUDE_MODEL_PRESETS, which no longer
exists; they now name CLAUDE_MODEL_FALLBACK_OPTIONS.
…alog route

The native client contract is the SSOT for the iOS and Android tracks. It
still described POST /api/sessions/:id/model without the optional effort
field that lets a model change clear an incompatible effort in the same write,
and omitted GET /api/machines/:id/claude-models entirely.
MODEL_OPTIONS.claude spread the fallback rows wholesale, so supportedEffortLevels
rode into objects that getClaudeComposerModelOptions deliberately projects it
out of, and a test pinned the leaked shape as correct.

Alongside it: getNextClaudeComposerModel landed on the null Default row for an
unrecognized model while the sibling cycler was fixed to land on the first
concrete row; setModel memoized on the whole mutation object rather than the
stable mutateAsync; and the machines-route fixture asserted a supportsFastMode
field normalizeClaudeModels strips.
…t wrong

The native PRESETS lists are the key set of CLAUDE_MODEL_LABELS -- the
recognition aliases, seven ids including the [1m] pairs. Annotating them as
ports of CLAUDE_MODEL_FALLBACK_OPTIONS pointed at the offer list, which has
four rows in a different order and no [1m], inverting the very role split this
branch introduces.

The reason recorded for keeping the Claude effort clamp was also wrong: the
levels do come from the catalog row, and the probe passes CLI strings through
unvalidated while both pickers render an unknown level. The clamp stays for a
different reason, now stated.

Claude leaving the model clamp took the only coverage of its true branch with
it; the replacement pins it on agy and fails if the clamp is deleted.
…ve catalogs

The native ports derived their picker from the key set of CLAUDE_MODEL_LABELS,
which conflated the two roles this branch separates: the labels are recognition
aliases and stay wide, while the picker offers one row per family. Adding Haiku
to the labels therefore grew the picker as a side effect, and package-tests
caught it against the pinned web option list.

Both tracks now carry an explicit FALLBACK_PRESETS list mirroring
CLAUDE_MODEL_FALLBACK_OPTIONS - opus, fable, sonnet, haiku - and both the
create-session form and the composer picker read it, while the label maps keep
resolving the [1m] ids for sessions created before they were dropped. This is
the same shape as the web, where CLAUDE_MODEL_PRESETS was deleted rather than
narrowed.

@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] Serialize background effort reconciliation — the effect still starts setEffort(null) without awaiting or disabling conflicting actions. A prompt can start with the unsupported model/effort pair before the correction lands, and a model switch to a supporting model can be followed by this stale clear, silently discarding the pinned effort. Evidence web/src/components/SessionChat.tsx:1717.
    Suggested fix:
    const [isReconcilingClaudeEffort, setIsReconcilingClaudeEffort] = useState(false)
    
    setIsReconcilingClaudeEffort(true)
    void handleEffortChange(null, { silent: true })
        .finally(() => setIsReconcilingClaudeEffort(false))
    
    <HappyComposer disabled={props.isSending || isReconcilingClaudeEffort} />
  • [Major] Reconcile capabilities known by the static fallback — resolveClaudeSupportedEffortLevels() deliberately returns confirmed fallback capabilities when the live catalog is empty (for example, haiku -> []), and both selectors then render only Auto. However, the reconciliation guards exit while the query is loading/errored or no live row exists, so the stored high value remains and is still submitted despite the UI displaying Auto. The same mismatch exists in New Session. Evidence web/src/components/SessionChat.tsx:1670, web/src/components/NewSession/index.tsx:633.
    Suggested fix:
    const supportedLevels = resolveClaudeSupportedEffortLevels(
        claudeComposerModelValue,
        claudeModelsState.availableModels
    )
    if (
        agentFlavor !== 'claude'
        || !props.session.active
        || !props.session.effort
        || supportedLevels === undefined
    ) return
    Apply the same rule in New Session: gate on claudeEffortOptions !== undefined, not the live query's loading/error state.
  • [Major] Update the iOS catalog conformance expectation — adding Haiku to ClaudeModels.presets changes NewSessionCatalogs.claudeModels, but the expected array was not updated. The current package-tests check fails at CatalogTests.swift:244, so this head cannot pass required CI. Evidence ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:22.
    Suggested fix:
    NewSessionOption(value: "haiku", label: "Haiku"),

Summary

Review mode: follow-up after new commits. Three Major issues remain: two effort-reconciliation correctness gaps and one confirmed required-check failure.

Testing

  • Not run locally (automation/security restriction); static review only.
  • git diff --check passed.
  • GitHub checks: build, app-build, integration, drift-gate, and main test pass; package-tests fails in newSessionCatalogsMatchTheWebOptionLists().
  • Add component tests covering a deferred reconciliation while send/model controls are disabled, plus catalog-error/fallback handling for a persisted haiku + high state in both Session Chat and New Session.

HAPI Bot

Comment thread web/src/components/SessionChat.tsx Outdated
Comment thread web/src/components/SessionChat.tsx Outdated
Comment thread ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift Outdated
…ange race

The catalog effect clears an effort the current model does not support, and
the user can change model while that write is in flight. Landing it afterwards
silently dropped a level the new model does support.

The effort route now takes an optional expectedModel and skips the write when
the session has moved on, answering {ok: true, skipped: 'model-changed'}.
Senders that omit the field keep last-write-wins, so no other caller changes.
It carries session.model verbatim rather than the resolved wire value, so both
sides compare the same stored field and no catalog resolution is involved.

@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] Make the model precondition atomic with the config mutation — the new expectedModel check reads the hub cache before applySessionConfig() awaits the CLI. A model RPC can already be in flight while the cache still contains the old model, so the stale effort clear is admitted and can execute after that model switch. Evidence hub/src/web/routes/sessions.ts:775.
    Suggested fix:
    const result = await engine.applySessionConfigIfModelMatches(
        sessionResult.sessionId,
        parsed.data.expectedModel,
        { effort: parsed.data.effort }
    )
    if (result === "model-changed") {
        return c.json({ ok: true, skipped: "model-changed" })
    }
    The precondition must be evaluated in the same serialized or CLI-side critical section as the model/effort mutation; a route-level cache check is not sufficient.

Summary

Review mode: follow-up after new commits. The fallback reconciliation fix is present, but the stale-clear Major remains partially unresolved because the compare-and-set is not atomic with session configuration RPCs.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: package-tests, app-build, build, drift-gate, and integration passed; test was pending.
  • Add a concurrency test that pauses an in-flight model applySessionConfig, starts the expectedModel effort clear while the cache still has the old model, then verifies the clear is skipped after the model mutation wins.

HAPI Bot

Comment thread hub/src/web/routes/sessions.ts Outdated
Grok drives its composer effort options from a model-dependent list exactly
like Claude now does - resolveComposerEffortOptions treats the two in the same
branch - and it does not correct a stored level the current model no longer
offers. The selector simply stops offering it. Claude was the only flavor
issuing a corrective write from the live composer, and that write is what the
compare-and-set, the reconciliation gate and its guards all existed to defend:
a background mutation racing the user's own model changes, across a network
boundary where the check and the write cannot be made atomic from the client.

Removing it removes the race rather than narrowing it, and the invariant it was
protecting was never load-bearing: the picker already refuses to offer an
unsupported level, and the CLI accepts --effort alongside a model that does not
advertise it. NewSession keeps reconciling because that is local form state
before the session exists, which is where grok and pi reconcile too.

This reverts the effort route's expectedModel precondition along with it.

@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] Scope the combined model+effort write to Claude — supportsEffort() also admits Pi, but Pi applies set_model and set_thinking_level sequentially. If the second RPC fails, Pi has already committed the new model while this route returns 409 and the hub never persists the applied model. Evidence hub/src/web/routes/sessions.ts:699, with the sequential mutation at cli/src/pi/runPi.ts:790 and cli/src/pi/runPi.ts:814.
    Suggested fix:
    if (parsed.data.effort !== undefined && flavor !== 'claude') {
        return c.json({ error: 'Combined model and effort changes are only supported for Claude sessions' }, 400)
    }

Summary

Review mode: follow-up after new commits. The previous reconciliation race is removed, but its replacement broadens the combined request to Pi, where the operation is not atomic and can leave CLI and hub state inconsistent.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: app-build, build, drift-gate, integration, and package-tests passed; test and pr-review were pending.
  • Add a Pi route test that rejects { model, effort }, or make the Pi handler transactional and test an effort failure after a successful model change.

HAPI Bot

Comment thread hub/src/web/routes/sessions.ts
The capability guard admits every flavor with supportsEffort, so the payload
was reaching Pi as well. Pi does not apply the pair as one operation: its CLI
awaits set_model and commits currentModel, then awaits set_thinking_level
(cli/src/pi/runPi.ts). A failing thinking-level call leaves Pi on the new model
while this route reports 409 and applySessionConfig never refreshes the hub
cache, so the two disagree with no path back.

Claude's session config RPC does apply them together, which is the only reason
the combined payload exists. Other flavors get a 400 pointing them at the two
routes; a plain model change is unaffected.

@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] Validate restored Claude selections against the target cwd catalog — New Session now feeds a machine-wide saved model into the composer-style option builder, which deliberately re-adds models missing from the live catalog. A catalog-only model saved in cwd A therefore remains selectable and is submitted in cwd B even after cwd B's catalog omits it. Create is also not gated while the Claude query is waiting/loading, so restored model/effort values can be submitted before the reconciliation effect runs. Evidence web/src/components/NewSession/index.tsx:578.
    Suggested fix:
    const claudeSelectionValid = model === 'auto'
        || findCatalogRowFor(model, claudeModelsState.availableModels)
        || getClaudeModelLabel(model) !== null // recognized legacy aliases only
    
    useEffect(() => {
        if (agent !== 'claude' || claudeModelsState.isLoading || claudeModelsState.error) return
        if (claudeModelsState.availableModels.length > 0 && !claudeSelectionValid) {
            setModel('auto')
            setEffort('auto')
        }
    }, [agent, claudeModelsState, claudeSelectionValid])
    
    // Add to isLaunchPreferenceValidationPending:
    || (agent === 'claude'
        && (model !== 'auto' || effort !== 'auto')
        && (deferredDirectoryExists === undefined || claudeModelsState.isLoading))
  • [Major] Keep Haiku out of native offer lists until native effort handling is model-aware — Android and iOS now offer Haiku, but both native new-session/config sheets still expose every Claude effort and their model mutations send only model, leaving an existing high/max effort pinned. The web path added in this PR treats Haiku as zero-effort and clears effort with the model change; native users can now create or switch to an unsupported pair. Evidence android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionCatalogs.kt:22, mirrored by ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:22.
    Suggested fix:
    // Keep Haiku recognized, but do not offer it until native effort options
    // and model+effort clearing are derived from the selected model.
    val FALLBACK_PRESETS = listOf("opus", "fable", "sonnet")

Summary

  • Review mode: follow-up after new commits. The previous Pi combined-write issue is addressed. Two remaining regressions allow a restored model to bypass the cwd-scoped catalog and allow native clients to select Haiku with unsupported effort.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: app-build, build, drift-gate, integration, package-tests, and test passed; pr-review was pending.
  • Add a web test for a saved catalog-only Claude model across cwd changes/loading, plus iOS/Android tests asserting Haiku cannot retain or submit a non-auto effort.

HAPI Bot

Comment thread web/src/components/NewSession/index.tsx Outdated
New Session was building its Claude options with the composer builder, which
deliberately folds a current value the catalog omits back in. That is right for
an already-running session carrying a legacy id, but the create form's saved
preference is machine-wide while the catalog is per-cwd, so a catalog-only
model saved in one directory stayed selectable and submittable in another.

Claude now follows what grok already does in this form: options come from the
catalog alone, a restored model the loaded catalog does not list resets to
Default, and Create waits while the catalog is still loading and a non-default
model or effort is pinned. The probe-failure path is unchanged - there is no
catalog to judge against, so the saved value stays visible and the reset is
skipped in the same case.

@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] Keep Haiku out of native offer lists until effort handling is model-aware — Android now offers Haiku while New Session still exposes the full static Claude effort list and chat model changes still post only the model. iOS mirrors both behaviors. A native user can therefore create or switch to Haiku while retaining high/max, even though this PR defines Haiku as supporting no effort. Evidence android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionCatalogs.kt:22, related context android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt:638, android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt:1171, and ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:22.
    Suggested fix:
    val CLAUDE_MODELS = listOf(
        OptionItem("auto", "Default"),
        OptionItem("opus", "Opus"),
        OptionItem("fable", "Fable"),
        OptionItem("sonnet", "Sonnet"),
    )
    Mirror the temporary offer-list restriction in Android core and Swift until native model selection derives effort options and clears unsupported effort atomically.
  • [Minor] Map the catalog default row onto the existing auto sentinel — the normal CLI catalog contains a literal default row, but this branch prepends auto and then maps every catalog row verbatim. New Session therefore renders two Default choices; choosing the second persists and submits the literal default because only auto is omitted at spawn. Evidence web/src/components/NewSession/index.tsx:592.
    Suggested fix:
    const defaultRow = claudeModelsState.availableModels.find((candidate) => candidate.value === 'default')
    return [
        { value: 'auto', label: defaultRow?.displayName ?? 'Default' },
        ...claudeModelsState.availableModels
            .filter((candidate) => candidate.value !== 'default')
            .map((candidate) => ({ value: candidate.value, label: candidate.displayName }))
    ]

Summary
Review mode: follow-up after new commits. The saved-model/cwd finding from the prior review is fixed. The native Haiku/effort regression remains, and the follow-up introduced a duplicate/default-sentinel regression in New Session.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: drift-gate, package-tests, Android build, integration, and iOS app-build passed; test and pr-review were still in progress.
  • Add a New Session test that asserts the live catalog default row produces exactly one Default option and submits no explicit model, plus native tests preventing Haiku with a non-auto effort.

HAPI Bot

Comment thread web/src/components/NewSession/index.tsx Outdated
The catalog carries its own `default` row and the form already prepends an
'auto' row labelled Default, so switching this picker to catalog-only rows in
the previous commit rendered two Default choices. Picking the second submitted
the literal string `default`, because handleCreate only translates 'auto' away.
The composer builder maps the same row onto its null sentinel; this one filters
it, since 'auto' is the sentinel here.

@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] Keep Haiku out of native offer lists until effort handling is model-aware — Android New Session and both native chat pickers still expose the full static Claude effort list, while this PR adds Haiku and defines it as supporting no effort. Selecting or switching to Haiku can therefore preserve or submit high/max instead of clearing effort atomically. Evidence android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionCatalogs.kt:22; related context android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt:638, android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt:1171, ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:22, and ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractor.swift:641.
    Suggested fix:
    val CLAUDE_MODELS: List<OptionItem> = listOf(
        OptionItem("auto", "Default"),
        OptionItem("opus", "Opus"),
        OptionItem("fable", "Fable"),
        OptionItem("sonnet", "Sonnet"),
    )
    Mirror the temporary restriction in Android core and Swift until native clients derive effort options per model and send { model, effort: null } atomically.

Summary
Review mode: follow-up after new commits. The new commit resolves the prior web duplicate/default-sentinel finding. The native Haiku/effort regression remains. No additional high-confidence issues found in the full latest diff. Residual risk: the Claude control-protocol behavior is mock-tested here; this review did not execute a real CLI probe.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: Android build, fixtures drift-gate, integration, iOS package tests, and iOS app build passed; the main test and PR-review jobs were still in progress.
  • Missing coverage: native behavioral tests that selecting or switching to Haiku hides/clears effort and sends the model+effort change atomically.

HAPI Bot

Haiku is the first Claude model that supports no effort, and the native
pickers offer a static effort list and post model and effort separately. So
while the web can offer Haiku and narrow its effort options to Auto, offering
it natively would let a session sit on Haiku with high or max pinned, or be
created that way.

It stays in both label maps, so a session already on Haiku still renders with
its name rather than the raw id, and the create-form conformance test records
why this one row diverges from the web offer list. Add it back once the native
effort offers are model-aware.

@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

  • None.

Summary
Review mode: follow-up after new commits. The prior Major finding is resolved by removing Haiku from the native offer lists while retaining recognition for existing sessions. No additional high-confidence issues found in the full latest diff. Residual risk: Claude control-protocol behavior and installed-version compatibility were reviewed statically; no real CLI probe was executed in this automation run.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: Android build, fixtures drift-gate, integration, iOS package tests, and iOS app build passed; the main test and PR-review jobs were still in progress.
  • Coverage present for probe parsing/failure/cache/single-flight behavior, context-window seed ordering, picker mapping and effort capability, hub routes, and native catalog parity.

HAPI Bot

Several comments and test names cited the review rounds that produced them.
Those identifiers name a process outside this repository, so a reader has no
way to reach what they point at; the reasoning they introduced is kept, the
citations are not.
@junmo-kim junmo-kim closed this Aug 23, 2026
@junmo-kim junmo-kim reopened this Aug 23, 2026

@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] Worktree sessions lose the live Claude catalog -- SessionChat sends metadata.path as the probe cwd, but HAPI-created worktrees live in a sibling <repo>-worktrees/ directory while the machine RPC rejects paths outside configured workspace roots. Every active Claude worktree session therefore silently falls back to static models/efforts; that can expose values absent from the account/org catalog and miss values that are available. Evidence: web/src/components/SessionChat.tsx:986, with related context cli/src/runner/worktree.ts:120 and cli/src/api/apiMachine.ts:280.

    Suggested fix:

    const claudeCatalogCwd =
        props.session.metadata?.worktree?.basePath
        ?? props.session.metadata?.path
        ?? null
    
    const claudeModelsState = useClaudeModelsForCwd({
        api: props.api,
        machineId: props.session.metadata?.machineId ?? null,
        cwd: claudeCatalogCwd,
        enabled: shouldDriveClaudeCatalog(agentFlavor, props.session.active)
    })

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. One Major issue found in the full latest diff: HAPI-managed worktree sessions cannot use the newly added live Claude catalog because the query targets the generated out-of-root worktree path.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: Android build, fixtures drift-gate, integration, iOS package tests/app build, and the main test job passed.
  • Missing coverage: a Claude session with metadata.worktree.basePath plus an out-of-root metadata.path should query the catalog using the base path.

HAPI Bot

Comment thread web/src/components/SessionChat.tsx Outdated
A HAPI-created worktree lives in a sibling <repo>-worktrees/ directory, which
falls outside a workspace root configured as the repo itself. The machine RPC
rejects such a cwd, so every active Claude worktree session was silently
dropping to the static model and effort lists - offering values the account
may not have and hiding ones it does.

The repo root the worktree was cut from is inside that root, and the probe
runs with --setting-sources user, so the catalog does not depend on which of
the two directories it ran in. Sessions without a worktree are unaffected.

@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] All-no-effort catalogs fall back to the full static effort list - capability reporting is inferred only when at least one row carries supportedEffortLevels. A current account/org policy that exposes only Haiku can therefore return only rows without that field; this branch returns undefined, so New Session and the composer expose the static effort levels and the model-change path does not carry the intended effort clear. That can submit a pinned effort such as high for a model the catalog says supports none. Evidence: web/src/components/AssistantChat/claudeModelOptions.ts:144.

    Suggested fix:

    const row = findCatalogRowFor(modelValue, availableModels)
    if (!row) return undefined
    
    if (!catalogReportsEffortLevels(availableModels)) {
        const fallbackValue = row.value === 'default'
            ? availableModels.find((candidate) =>
                candidate.value !== 'default'
                && candidate.resolvedModel === row.resolvedModel
            )?.value
            : row.value
        return resolveClaudeFallbackSupportedEffortLevels(fallbackValue)
    }
    
    return row.supportedEffortLevels ?? []

Questions

  • None.

Summary

Review mode: follow-up after new commits. One Major issue remains in the full latest diff: policy-filtered catalogs containing only no-effort models are treated as unconfirmed and regain invalid static effort choices.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: main test, integration, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
  • Missing coverage: a live catalog containing only default + haiku rows with no supportedEffortLevels field should resolve Haiku/default to zero effort options and clear a pinned effort during a model change.

HAPI Bot

Comment thread web/src/components/AssistantChat/claudeModelOptions.ts Outdated
…alog is silent

A catalog where no row carries supportedEffortLevels is ambiguous on the wire:
it is either an older CLI that never reports the field, or an account whose
only visible model happens to support none. Returning undefined for both meant
an account seeing only Haiku got the full static effort list and no atomic
clear on a model change.

The measured static table already records Haiku as supporting no effort and the
other families as supporting all five, so deferring to it lands on the right
answer under either reading. A model the table does not carry stays undefined,
since nothing has confirmed anything about it.

@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] The all-no-effort fallback still misses a Haiku-backed Default selection — when no catalog row reports supportedEffortLevels, this branch falls back using the caller's raw modelValue. New Session passes "auto" and the composer passes null for Default, so the static lookup returns undefined even when the catalog's default.resolvedModel matches its only concrete haiku row. Both surfaces then restore the full static effort list, and switching to Default does not atomically clear a pinned effort; an account whose default/only model is Haiku can still submit high. Evidence: web/src/components/AssistantChat/claudeModelOptions.ts:156.

    Suggested fix:

    if (availableModels.length > 0 && !catalogReportsEffortLevels(availableModels)) {
        const row = findCatalogRowFor(modelValue, availableModels)
        if (!row) return undefined
        const fallbackValue = row.value === 'default'
            ? availableModels.find((candidate) =>
                candidate.value !== 'default'
                && candidate.resolvedModel === row.resolvedModel
            )?.value
            : row.value
        return resolveClaudeFallbackSupportedEffortLevels(fallbackValue)
    }
    return resolveClaudeFallbackSupportedEffortLevels(modelValue)

Questions

  • None.

Summary

Review mode: follow-up after new commits. One Major issue remains: the follow-up handles an explicitly selected Haiku, but not the common Default sentinel when that default resolves to Haiku.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: test, integration, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
  • Missing coverage: an all-no-effort catalog containing only default + haiku, sharing the same resolvedModel, should resolve null/auto to zero effort options and clear a pinned effort when switching to Default.

HAPI Bot

Comment thread web/src/components/AssistantChat/claudeModelOptions.ts Outdated
…he static lookup

The static-table fallback used the caller's raw value, and both surfaces pass a
sentinel for Default -- 'auto' from New Session, null from the composer. The
table carries no such row, so an account whose Default resolves to Haiku got
the full effort list back and lost the clear on a model change, even though the
catalog held the identity: the concrete row sharing the default row's
resolvedModel names the family it points at.

Selections the catalog cannot place, and catalog-only families the table does
not carry, still resolve to undefined.

@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] Native session settings still allow unsupported effort on explicit Haiku sessions — the new web catalog can switch a session to haiku, but iOS and Android still build the session effort picker from the unconditional static lowmax list. Opening that session on mobile can therefore pin high again through /effort, defeating the model-capability handling added by this PR. Evidence ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:39, ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift:248, android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Models.kt:20, android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt:1289.

    Suggested fix:

    public static func claudeEffortOptions(
        currentModel: String?,
        currentEffort: String?
    ) -> [CatalogOption] {
        if normalizeClaudeModel(currentModel) == "haiku" {
            return [CatalogOption(value: nil, label: "Auto")]
        }
        // existing option construction
    }
    
    effortOptions = ModelCatalog.claudeEffortOptions(
        currentModel: model,
        currentEffort: effort
    )

    Mirror the model-aware signature/guard in Android.

Questions

  • None.

Summary

Review mode: follow-up after new commits. The prior Default-to-Haiku effort-resolution finding is addressed. One Major cross-client gap remains: native session settings can reattach an unsupported effort to a Haiku session created or changed on the web.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at review time: test, integration, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
  • Missing coverage: iOS and Android session-config tests should assert that an explicit haiku model exposes only Auto and cannot submit a non-null effort.

HAPI Bot

…ffort

Both native config sheets built the Claude effort picker from the unconditional
static level list. A session switched to Haiku in the web app could be opened
on mobile and pinned back to high, so the capability the catalog reports was
enforced on one surface only.

Both tracks now carry the effortless-family set ported from
CLAUDE_MODEL_FALLBACK_OPTIONS, and the effort builder takes the model. Unknown,
Default and omitted selections stay permissive - nothing has said those lack
effort - and a [1m] suffix resolves to its bare family.

@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] Resolved Haiku IDs bypass the no-effort guard - this PR explicitly supports existing sessions whose stored Claude model is a resolved SDK id, but the fallback capability lookup only strips [1m] and exact-matches aliases. With no live catalog, claude-haiku-4-5-20251001 therefore resolves to "unknown" and the web composer exposes the full effort list; the new iOS/Android guards have the same exact-haiku limitation. Those clients can reattach high to a Haiku session. Evidence web/src/components/AssistantChat/claudeModelOptions.ts:98, ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:43, android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Models.kt:28.

    Suggested fix:

    function claudeFamily(model: string): string {
        const normalized = stripClaude1mSuffixForFallbackMatch(model.trim().toLowerCase())
        if (normalized === 'haiku' || normalized.startsWith('claude-haiku-')) return 'haiku'
        return normalized
    }
    
    const family = claudeFamily(modelValue)
    return CLAUDE_MODEL_FALLBACK_OPTIONS.find(
        (option) => option.value === family
    )?.supportedEffortLevels

    Mirror the resolved-id family normalization in Swift/Kotlin.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. The prior short-alias native finding is addressed, but resolved Haiku IDs still bypass effort capability handling when the live catalog is unavailable and in native clients.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at submission: test, integration, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
  • Missing coverage: web fallback plus iOS/Android catalog tests for claude-haiku-4-5-20251001 should assert Auto-only effort.

HAPI Bot

Comment thread web/src/components/AssistantChat/claudeModelOptions.ts Outdated
A stored model can be a preset, a legacy [1m] alias, or a resolved SDK id, and
the capability lookups only stripped [1m] and exact-matched the short aliases.
A session carrying claude-haiku-4-5-20251001 therefore looked unknown, so the
web composer offered the full effort list and the new native guards let it be
pinned back to high.

Resolved ids are claude-<family>-<generation>..., so shared/src/models.ts now
exposes resolveClaudeModelFamily, which reads the family structurally rather
than from an id-by-id table, and both native ports mirror it. Ids whose family
the offer list does not carry still resolve to undefined.

@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

  • None.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. No issues found in the latest full diff. The prior resolved-Haiku effort-capability finding is addressed across web, iOS, and Android. Residual risk is limited to platform-specific regression coverage noted below.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at submission: test, integration, build, fixtures drift-gate, iOS package tests, and iOS app build passed.
  • Missing coverage: Android has no targeted assertions for ClaudeModels.family / supportsEffort or ModelCatalog.claudeEffortOptions with a resolved Haiku id such as claude-haiku-4-5-20251001.

HAPI Bot

…atalog

# Conflicts:
#	hub/src/sync/rpcGateway.ts
#	web/src/api/client.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

  • None.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. No issues found in the latest full diff. The update since the prior HAPI Bot pass is an upstream/main merge; the complete current diff was re-reviewed. Residual risk is limited to the testing gap noted below.

Testing

  • Not run (automation/security restriction); static review only.
  • GitHub checks at submission: unit tests, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
  • The integration job fails in four runner integration cases, but the current base commit 4948d23 fails in the same four cases, so this was not attributed to the PR diff.
  • Missing coverage: Android has no targeted assertions for ClaudeModels.family / supportsEffort or the Auto-only Haiku effort options.

HAPI Bot

@junmo-kim junmo-kim closed this Aug 30, 2026
@junmo-kim junmo-kim reopened this Aug 30, 2026
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