Skip to content

Commit 55c2705

Browse files
anandgupta42claude
andcommitted
fix: snapshot the deferred-submit revision serialized; share the onboardingReady() predicate; re-defer test; orphan marker
- util/signal.ts: `createDeferredRetry` captured `getRevision()` by reference. Solid's `unwrap` returns the store's raw underlying object, the same one later edits mutate in place, so the defer-time value and the retry-time value were one object and an edited prompt was still auto-sent. The defer-time revision is now stored serialized and the live value is serialized again before retrying (cursor 3987286236, cubic 3987320771). ready-pending.test.tsx's harness now hands the primitive one live mutable object, the production shape; its edited-while-deferred test fails on the old primitive. - ready-pending.test.tsx: the "re-deferred" test now actually re-defers (Enter twice while pending) and asserts exactly one send (kilo 3987319604). - context/local.tsx + app.tsx: the onboardingReady() branch's discriminator is an exported `shouldFireFirstRunFunnelAtStartup` that app.tsx calls and local.test.ts tests, instead of an expression the tests re-derived. - app.tsx: remove an orphan `// altimate_change end`. Co-Authored-By: Claude Fable 5.1 <[email protected]>
1 parent fdae60d commit 55c2705

5 files changed

Lines changed: 50 additions & 20 deletions

File tree

‎packages/tui/src/app.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import { SyncProvider, useSync } from "./context/sync"
6161
import { DataProvider } from "./context/data"
6262
// altimate_change — fixes #1301 (Codex review, P2): `ALTIMATE_BASE_MIGRATION_DECLINED_KEY` moved
6363
// to local.tsx so `local.model.hasUsableFreeDefault()` can read the same kv key.
64-
import { LocalProvider, useLocal, ALTIMATE_BASE_MIGRATION_DECLINED_KEY, shouldSkipOnboardingAtStartup } from "./context/local"
64+
import { LocalProvider, useLocal, ALTIMATE_BASE_MIGRATION_DECLINED_KEY, shouldSkipOnboardingAtStartup, shouldFireFirstRunFunnelAtStartup } from "./context/local"
6565
import { DialogModel } from "./component/dialog-model"
6666
import { useConnected } from "./component/use-connected"
6767
import { DialogMcp } from "./component/dialog-mcp"
@@ -790,7 +790,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
790790
// altimate-onboarding.tsx) is required alongside it, same as the branch above: true only
791791
// when the first-run picker itself actually opened THIS launch (this effect's own
792792
// fallthrough below, or the prompt gate's equivalent).
793-
if (setupComplete() && firstRunOpenedThisLaunch()) {
793+
if (shouldFireFirstRunFunnelAtStartup(setupComplete(), firstRunOpenedThisLaunch())) {
794794
// Deliberately NOT markFirstRunActive() again here: it was already latched at the point
795795
// the picker opened (this branch only reaches telemetry when `firstRunOpenedThisLaunch()`
796796
// is already true), and `markSetupComplete()` has already run on this branch and will not
@@ -822,7 +822,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
822822
startupDecisionHandled = true
823823
// altimate_change end
824824
})
825-
// altimate_change end
826825

827826
// altimate_change start — Part 2 scan gate: fire EXACTLY once, when the user has actually
828827
// finished picking a model during a first run.

‎packages/tui/src/context/local.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,15 @@ export function shouldSkipOnboardingAtStartup(
199199
): boolean {
200200
return (hasExistingLegacySelection || hasUsableFreeDefault) && !setupCompleteThisLaunch
201201
}
202+
203+
// The onboardingReady() branch's own discriminator (app.tsx), extracted like its sibling above so
204+
// the test exercises the SAME predicate app.tsx calls rather than re-deriving the expression
205+
// (review of #1302, round 10). `setupComplete` alone is a GLOBAL flag set by any model pick; a
206+
// returning user's routine `/model` switch racing the startup effect must not read as a first-run
207+
// completion — only a first-run picker that actually opened THIS launch qualifies.
208+
export function shouldFireFirstRunFunnelAtStartup(setupComplete: boolean, firstRunOpenedThisLaunch: boolean): boolean {
209+
return setupComplete && firstRunOpenedThisLaunch
210+
}
202211
// altimate_change end
203212

204213
// altimate_change start — fixes #1301 (Codex review round 2, P1): an older picker-written Zen

‎packages/tui/src/util/signal.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-
1010
// reimplementation of this exact shape, which meant reverting the real fix in prompt/index.tsx
1111
// left the test passing regardless, since it never touched production code at all.
1212
//
13-
// `options.getRevision`, if given, is called ONCE at `.defer()` time (capturing whatever it
14-
// returns) and again right before `retry()` would fire — if the two differ (by JSON equality),
15-
// `retry()` is skipped entirely rather than fired against stale state. This is what
13+
// `options.getRevision`, if given, is called ONCE at `.defer()` time and again right before
14+
// `retry()` would fire — if the two differ (by JSON equality), `retry()` is skipped entirely
15+
// rather than fired against stale state. The defer-time value is captured SERIALIZED, never as a
16+
// reference: Solid's `unwrap` hands back the store's raw underlying object, the very one later
17+
// edits mutate in place, so holding it and stringifying both sides at retry time compared the
18+
// object to itself and could never see an edit (cursor 3987286236 / cubic 3987320771). This is what
1619
// component/prompt/index.tsx's submit gate uses to snapshot the prompt (text + attachments) at
1720
// the moment a submission defers: without it, a user who deferred prompt A, then edited the box
1821
// to B WITHOUT pressing Enter again, would have B silently auto-submitted the instant readiness
@@ -23,17 +26,18 @@ export function createDeferredRetry<T = void>(
2326
options?: { getRevision?: () => T },
2427
) {
2528
let deferred = false
26-
let capturedRevision: T | undefined
29+
let capturedRevision: string | undefined
30+
const snapshot = () => (options?.getRevision ? JSON.stringify(options.getRevision()) : undefined)
2731
createEffect(() => {
2832
if (pending() || !deferred) return
2933
deferred = false
30-
if (options?.getRevision && JSON.stringify(options.getRevision()) !== JSON.stringify(capturedRevision)) return
34+
if (options?.getRevision && snapshot() !== capturedRevision) return
3135
retry()
3236
})
3337
return {
3438
defer() {
3539
deferred = true
36-
capturedRevision = options?.getRevision?.()
40+
capturedRevision = snapshot()
3741
},
3842
}
3943
}

‎packages/tui/test/context/local.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
// altimate_change end
2727
// altimate_change start — Kilo review round 6: app.tsx startup onboarding-skip discriminator
2828
shouldSkipOnboardingAtStartup,
29+
shouldFireFirstRunFunnelAtStartup,
2930
// altimate_change end
3031
// altimate_change start — fixes #1301 (Codex review round 2, P1): migration correctness
3132
isOwnPastPickOfFreeDefault,
@@ -467,9 +468,9 @@ test("onboardingReady() branch: a returning user's /model switch to a paid model
467468
expect(setupComplete()).toBe(true)
468469
expect(firstRunOpenedThisLaunch()).toBe(false)
469470

470-
// Mirrors app.tsx's actual condition at the onboardingReady() branch exactly — must be false,
471-
// or onboarding telemetry and the scan gate fire for a routine provider switch.
472-
expect(setupComplete() && firstRunOpenedThisLaunch()).toBe(false)
471+
// The SAME predicate app.tsx's onboardingReady() branch calls — must be false, or onboarding
472+
// telemetry and the scan gate fire for a routine provider switch.
473+
expect(shouldFireFirstRunFunnelAtStartup(setupComplete(), firstRunOpenedThisLaunch())).toBe(false)
473474
} finally {
474475
resetSetupComplete()
475476
}
@@ -487,7 +488,7 @@ test("onboardingReady() branch: a genuine first-run completion (the picker opene
487488
expect(setupComplete()).toBe(true)
488489
expect(firstRunOpenedThisLaunch()).toBe(true)
489490

490-
expect(setupComplete() && firstRunOpenedThisLaunch()).toBe(true)
491+
expect(shouldFireFirstRunFunnelAtStartup(setupComplete(), firstRunOpenedThisLaunch())).toBe(true)
491492
} finally {
492493
resetSetupComplete()
493494
}

‎packages/tui/test/context/ready-pending.test.tsx‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ function DeferThenRetryHarness(props: {
6060
pending: () => boolean
6161
promptText: () => string
6262
setPromptText: (value: string) => void
63+
/** The SAME object on every call, mutated in place by `setPromptText` — exactly what
64+
* `unwrap(store.prompt)` hands component/prompt/index.tsx (cursor 3987286236). */
65+
livePrompt: () => { input: string }
6366
onSend: (value: string) => void
6467
onDiscard: () => void
6568
exposeSubmit: (fn: () => boolean) => void
@@ -79,12 +82,14 @@ function DeferThenRetryHarness(props: {
7982
props.setPromptText("")
8083
return true
8184
}
82-
// altimate_change — Codex re-review round 9: `getRevision` mirrors component/prompt/index.tsx's
83-
// real usage (a snapshot of the prompt at defer time, compared against the live prompt right
84-
// before retrying) — here the "prompt" is just `promptText()` itself, so the revision IS the
85-
// text.
85+
// altimate_change — Codex re-review round 9 / cursor 3987286236: `getRevision` mirrors
86+
// component/prompt/index.tsx's real usage exactly — it returns the store's LIVE raw object (the
87+
// same reference every call, mutated in place by edits), not a fresh string. A primitive that
88+
// merely held that reference and stringified both sides at retry time compared the object to
89+
// itself and never saw an edit; this harness shape is what makes the edited-while-deferred test
90+
// below fail on that bug.
8691
const deferredSubmit = createDeferredRetry(props.pending, () => void attemptSubmit(), {
87-
getRevision: () => props.promptText(),
92+
getRevision: () => props.livePrompt(),
8893
})
8994
props.exposeSubmit(attemptSubmit)
9095
return null
@@ -93,7 +98,13 @@ function DeferThenRetryHarness(props: {
9398
async function mountHarness(options: { initialPending: boolean; willBeReady: boolean; promptText?: string }) {
9499
const [pending, setPending] = createSignal(options.initialPending)
95100
const ready = () => !pending() && options.willBeReady
96-
const [promptText, setPromptText] = createSignal(options.promptText ?? "hello from before kv.ready")
101+
const [promptText, setPromptTextSignal] = createSignal(options.promptText ?? "hello from before kv.ready")
102+
// One object, mutated in place — see `livePrompt` on the harness props.
103+
const livePrompt = { input: promptText() }
104+
const setPromptText = (value: string) => {
105+
livePrompt.input = value
106+
setPromptTextSignal(value)
107+
}
97108
const submitSpy: string[] = []
98109
let discarded = false
99110
let submit: (() => boolean) | undefined
@@ -104,6 +115,7 @@ async function mountHarness(options: { initialPending: boolean; willBeReady: boo
104115
pending={pending}
105116
promptText={promptText}
106117
setPromptText={setPromptText}
118+
livePrompt={() => livePrompt}
107119
onSend={(value) => submitSpy.push(value)}
108120
onDiscard={() => {
109121
discarded = true
@@ -205,12 +217,17 @@ test.serial(
205217
"defer-then-retry: a submission that is deferred and then re-deferred unchanged still sends once pending clears",
206218
async () => {
207219
// Guards against an overzealous fix: identical text at defer-time and retry-time (nothing
208-
// edited) must still send normally.
220+
// edited) must still send normally — and pressing Enter TWICE while pending (Kilo 3987319604:
221+
// two `.defer()` calls, the revision re-captured each time) must still yield exactly one send.
209222
const h = await mountHarness({ initialPending: true, willBeReady: true, promptText: "unchanged" })
210223
try {
211224
expect(h.attemptSubmit()).toBe(false)
225+
expect(h.attemptSubmit()).toBe(false)
226+
expect(h.submitSpy).toEqual([])
212227
h.setPending(false)
213228
await waitUntil(() => h.submitSpy.length > 0)
229+
// Bounded settle so a second (duplicate) retry would have had time to show up.
230+
await Bun.sleep(50)
214231
expect(h.submitSpy).toEqual(["unchanged"])
215232
expect(h.promptText()).toBe("")
216233
} finally {

0 commit comments

Comments
 (0)