Skip to content

fix(cli): accept the app's effort key in MessageMetaSchema - #1674

Open
chphch wants to merge 1 commit into
slopus:mainfrom
chphch:fix/cli-accept-effort-meta-key
Open

fix(cli): accept the app's effort key in MessageMetaSchema#1674
chphch wants to merge 1 commit into
slopus:mainfrom
chphch:fix/cli-accept-effort-meta-key

Conversation

@chphch

@chphch chphch commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The effort picker never reaches the SDK

happy-app sends the per-message effort under meta.effort (sync.ts:728). The CLI parses incoming messages with UserMessageSchema.safeParse (apiSession.ts:552), and MessageMetaSchema does not declare effort — so Zod, which strips unknown keys by default, removes it before anything downstream sees it.

runClaude then gates on the key that is already gone:

if (message.meta?.hasOwnProperty('effort')) {          // always false
    const incoming = (message.meta as Record<string, unknown>).effort;

The cast is the tell. effort is read through Record<string, unknown> precisely because it is absent from MessageMeta, so typecheck passes while the branch that applies the picked effort is unreachable. Every user message falls through to the else and keeps whatever effort the session was spawned with.

Reproduction

Parsing a message shaped exactly like the one the app sends, against this file before and after the change:

meta after parse meta?.hasOwnProperty('effort')
main {"sentFrom":"app"} false
this branch {"sentFrom":"app","effort":"high"} true

Input in both cases: {"sentFrom":"app","effort":"high"}.

Why the tests don't catch it

runClaude.test.ts builds meta as a TypeScript object literal and hands it straight to the loop, so it never crosses the schema. The mismatch exists only on the wire path, which no test exercises.

The change

One field on MessageMetaSchema. Nullable and optional to match the reset semantics the surrounding fields use, and the same null = reset behaviour runClaude already implements.


CI on this PR fails at the install step for the reason in #1663 (pnpm-lock.yaml out of sync with happy-cli/package.json since e7e0ff6), not because of this change.

The per-message effort picker never reaches the SDK. happy-app sends the
value under `meta.effort` (sync.ts:728), the CLI parses the message with
`UserMessageSchema.safeParse` (apiSession.ts:552), and `MessageMetaSchema`
does not declare `effort` — so Zod, which strips unknown keys by default,
deletes it before anything downstream can see it.

runClaude then gates on the key that is already gone:

    if (message.meta?.hasOwnProperty('effort')) {          // always false
        const incoming = (message.meta as Record<string, unknown>).effort;

That cast is the tell: the key is read through `Record<string, unknown>`
precisely because it is absent from the type, so typecheck passes while the
branch that applies the picked effort is unreachable. Every user message
falls through to the `else` and keeps the previous effort.

Reproduced against this file, before and after:

    app sends   {"sentFrom":"app","effort":"high"}
    parsed      {"sentFrom":"app"}                  <- effort dropped
    guard       meta?.hasOwnProperty('effort') = false

    with this change:
    parsed      {"sentFrom":"app","effort":"high"}
    guard       meta?.hasOwnProperty('effort') = true

The existing coverage cannot catch it: runClaude.test.ts builds `meta` as a
TypeScript object literal and never goes through the schema, so the wire path
is the only place the mismatch exists.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
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