Skip to content

Commit 17106e2

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@234f52a231ef5a53806c00b5a95ec4bdd8422bfa
1 parent 92c4f5e commit 17106e2

12 files changed

Lines changed: 529 additions & 52 deletions

bun.lock

Lines changed: 1 addition & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/app.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,12 @@ interface AuthedSurfaceProps {
293293
* we have a token).
294294
*/
295295
const AuthedSurface = (props: AuthedSurfaceProps) => {
296-
const { session, failure: sessionFailure } = useFreebuffSession()
296+
const {
297+
session,
298+
failure: sessionFailure,
299+
lastRefund,
300+
refundPending,
301+
} = useFreebuffSession()
297302

298303
return (
299304
<ChatRuntimeProvider
@@ -307,6 +312,8 @@ const AuthedSurface = (props: AuthedSurfaceProps) => {
307312
{...props}
308313
session={session}
309314
sessionFailure={sessionFailure}
315+
lastRefund={lastRefund}
316+
refundPending={refundPending}
310317
/>
311318
</ChatRuntimeProvider>
312319
)
@@ -329,9 +336,13 @@ const AuthedSurfaceRoutes = ({
329336
onNewChat,
330337
session,
331338
sessionFailure,
339+
lastRefund,
340+
refundPending,
332341
}: AuthedSurfaceProps & {
333342
session: ReturnType<typeof useFreebuffSession>['session']
334343
sessionFailure: ReturnType<typeof useFreebuffSession>['failure']
344+
lastRefund: ReturnType<typeof useFreebuffSession>['lastRefund']
345+
refundPending: boolean
335346
}) => {
336347
// Terminal state: a 409 from the gate means another CLI rotated our
337348
// instance id. Show a dedicated screen and stop polling — don't fall back
@@ -364,7 +375,14 @@ const AuthedSurfaceRoutes = ({
364375
session.status === 'ip_capped' ||
365376
session.status === 'takeover_prompt')
366377
) {
367-
return <FreebuffLandingScreen session={session} failure={sessionFailure} />
378+
return (
379+
<FreebuffLandingScreen
380+
session={session}
381+
failure={sessionFailure}
382+
lastRefund={lastRefund}
383+
refundPending={refundPending}
384+
/>
385+
)
368386
}
369387

370388
// Chat history renders inside AuthedSurface so the freebuff session stays

cli/src/components/freebuff-landing-screen.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ import type { KeyEvent } from '@opentui/core'
6767
interface FreebuffLandingScreenProps {
6868
session: FreebuffSessionResponse | null
6969
failure: FreebuffSessionFailure | null
70+
lastRefund: number | null
71+
refundPending: boolean
7072
}
7173

7274
/** Landing-screen heading. Referenced both as rendered text and by the
@@ -354,6 +356,8 @@ export const LandingHeadingRow: React.FC<{
354356
export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
355357
session,
356358
failure,
359+
lastRefund,
360+
refundPending,
357361
}) => {
358362
const theme = useTheme()
359363
const renderer = useRenderer()
@@ -679,6 +683,19 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
679683
</text>
680684
)}
681685

686+
{refundPending && (
687+
<text style={{ fg: theme.secondary, wrapMode: 'word' }}>
688+
Your refund is awaiting final usage. Once settled, it will appear
689+
in your wallet.
690+
</text>
691+
)}
692+
{lastRefund !== null && (
693+
<text style={{ fg: theme.secondary, wrapMode: 'word' }}>
694+
{lastRefund} {lastRefund === 1 ? 'Freebuck' : FREEBUCKS_LABEL}{' '}
695+
returned to your wallet.
696+
</text>
697+
)}
698+
682699
{!session && !failure && (
683700
<text style={{ fg: theme.muted }}>
684701
<ShimmerText text="Connecting…" />

cli/src/data/slash-commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
195195
{
196196
id: 'end-session',
197197
label: 'end-session',
198-
description: 'End your free session (lets you switch model)',
198+
description: 'End session; get 90% of unspent cost back, rounded down',
199199
aliases: ['model'],
200200
},
201201
{
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
2+
3+
import { useChatStore } from '../../state/chat-store'
4+
import { useFreebuffSessionStore } from '../../state/freebuff-session-store'
5+
import * as auth from '../../utils/auth'
6+
import { IS_FREEBUFF } from '../../utils/constants'
7+
import { getUserMessage } from '../../utils/message-history'
8+
import { returnToFreebuffLanding } from '../use-freebuff-session'
9+
10+
// Exercise the command's real transition in Freebuff mode:
11+
// FREEBUFF_MODE=true bun test src/hooks/__tests__/end-freebuff-session.test.ts
12+
// The React poll controller is absent; the transition still owns release/reset.
13+
describe.skipIf(!IS_FREEBUFF)('end-session transition', () => {
14+
let authSpy: ReturnType<typeof spyOn>
15+
let fetchSpy: ReturnType<typeof spyOn>
16+
const session = {
17+
status: 'active' as const,
18+
accessTier: 'full' as const,
19+
instanceId: 'held-cli',
20+
model: 'mimo/mimo-v2.5',
21+
admittedAt: '2099-09-07T12:00:00Z',
22+
expiresAt: '2099-09-07T13:00:00Z',
23+
remainingMs: 300000,
24+
}
25+
beforeEach(() => {
26+
authSpy = spyOn(auth, 'getAuthTokenDetails').mockReturnValue({
27+
token: 'test-token',
28+
source: 'environment',
29+
})
30+
fetchSpy = spyOn(globalThis, 'fetch').mockRejectedValue(
31+
new Error('offline'),
32+
)
33+
useFreebuffSessionStore.getState().setSession(session)
34+
useChatStore.getState().setMessages([getUserMessage('Keep my work')])
35+
})
36+
afterEach(() => {
37+
authSpy.mockRestore()
38+
fetchSpy.mockRestore()
39+
useFreebuffSessionStore.getState().setSession(null)
40+
useFreebuffSessionStore.getState().setFailure(null)
41+
useChatStore.getState().reset()
42+
})
43+
44+
test('failed end preserves history and identity, explains retry, then resets on confirmation', async () => {
45+
const original = useChatStore.getState().messages[0]
46+
await expect(returnToFreebuffLanding({ resetChat: true })).rejects.toThrow(
47+
'offline',
48+
)
49+
expect(useChatStore.getState().messages[0]).toEqual(original)
50+
expect(JSON.stringify(useChatStore.getState().messages)).toContain(
51+
'Retry /end-session',
52+
)
53+
expect(useFreebuffSessionStore.getState().session).toEqual(session)
54+
fetchSpy.mockResolvedValue(
55+
Response.json({ status: 'ended', freebucksRefund: 4 }),
56+
)
57+
await returnToFreebuffLanding({ resetChat: true })
58+
expect(useChatStore.getState().messages).toHaveLength(0)
59+
expect(useFreebuffSessionStore.getState().lastRefund).toBe(4)
60+
for (const [, init] of fetchSpy.mock.calls) {
61+
expect(new Headers(init.headers).get('x-freebuff-instance-id')).toBe(
62+
'held-cli',
63+
)
64+
}
65+
})
66+
67+
test('an old end cannot reset a replacement chat', async () => {
68+
let respond!: (response: Response) => void
69+
fetchSpy.mockImplementation(
70+
() =>
71+
new Promise<Response>((resolve) => {
72+
respond = resolve
73+
}),
74+
)
75+
const ending = returnToFreebuffLanding({ resetChat: true })
76+
useFreebuffSessionStore
77+
.getState()
78+
.setSession({ ...session, instanceId: 'new-cli' })
79+
const replacement = getUserMessage('New work')
80+
useChatStore.getState().setMessages([replacement])
81+
respond(Response.json({ status: 'ended', freebucksRefund: 4 }))
82+
await ending
83+
expect(useChatStore.getState().messages).toEqual([replacement])
84+
expect(useFreebuffSessionStore.getState().lastRefund).toBeNull()
85+
})
86+
})

cli/src/hooks/use-freebuff-session.ts

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import {
4242
holdsLiveFreebuffSlot,
4343
isFreebuffSessionTimeoutError,
4444
mergeCompactActiveSession,
45-
releaseFreebuffSlot,
4645
} from '../utils/freebuff-session-api'
4746
import {
4847
failedPollDelayMs,
@@ -212,16 +211,42 @@ async function restartFreebuffSession(
212211
// resetting its store so late deltas cannot land in the next session.
213212
if (opts.resetChat) {
214213
stopActiveRun('session-transition')
215-
useChatStore.getState().reset()
216214
}
217215
// Halt the running poll loop before we touch local stores or DELETE the
218216
// slot. Otherwise an in-flight GET could land mid-reset and overwrite
219217
// state, or the next scheduled tick could fire between DELETE and
220218
// restart() with stale assumptions. restart() re-aborts and re-arms
221219
// below; the extra abort here is cheap.
222-
controller?.abort()
223-
if (opts.releaseSlot) await releaseFreebuffSlot()
224-
await controller?.restart(mode)
220+
const currentController = controller
221+
const currentToken = getAuthTokenDetails().token
222+
const currentSession = useFreebuffSessionStore.getState().session
223+
const stillCurrent = () =>
224+
controller === currentController &&
225+
getAuthTokenDetails().token === currentToken &&
226+
useFreebuffSessionStore.getState().session === currentSession
227+
currentController?.abort()
228+
if (opts.releaseSlot) {
229+
try {
230+
await useFreebuffSessionStore.getState()
231+
.releaseSlot()
232+
} catch (error) {
233+
if (!stillCurrent()) throw error
234+
// Keep the chat and held instance: the server may already have credited
235+
// the refund. A retry must use that same instance to recover its receipt.
236+
useChatStore
237+
.getState()
238+
.setMessages((messages) => [
239+
...messages,
240+
getSystemMessage(
241+
`Could not confirm the session ended. Retry /end-session. ${error instanceof Error ? error.message : String(error)}`,
242+
),
243+
])
244+
throw error
245+
}
246+
}
247+
if (!stillCurrent()) return
248+
if (opts.resetChat) useChatStore.getState().reset()
249+
await currentController?.restart(mode)
225250
}
226251

227252
/**
@@ -356,7 +381,9 @@ export function markFreebuffSessionCountryBlocked(params: {
356381
controller?.apply({ status: 'country_blocked', ...params })
357382
// Best-effort DELETE so we don't hold a session row the server is already
358383
// refusing to serve at chat time.
359-
releaseFreebuffSlot().catch(() => {})
384+
useFreebuffSessionStore
385+
.getState().releaseSlot()
386+
.catch(() => {})
360387
}
361388

362389
/** Flip into the local `ended` state without an instanceId (server has lost
@@ -382,6 +409,8 @@ export function markFreebuffSessionEnded(): void {
382409
interface UseFreebuffSessionResult {
383410
session: FreebuffSessionResponse | null
384411
failure: ReturnType<typeof useFreebuffSessionStore.getState>['failure']
412+
lastRefund: number | null
413+
refundPending: boolean
385414
}
386415

387416
/**
@@ -400,6 +429,26 @@ interface UseFreebuffSessionResult {
400429
export function useFreebuffSession(): UseFreebuffSessionResult {
401430
const session = useFreebuffSessionStore((s) => s.session)
402431
const failure = useFreebuffSessionStore((s) => s.failure)
432+
const lastRefund = useFreebuffSessionStore((s) => s.lastRefund)
433+
const pendingRefund = useFreebuffSessionStore((s) => s.pendingRefund)
434+
useEffect(() => {
435+
if (!pendingRefund) return
436+
let cancelled = false
437+
let timer: ReturnType<typeof setTimeout>
438+
const poll = async () => {
439+
try {
440+
await useFreebuffSessionStore.getState().refreshRefund()
441+
} catch {
442+
/* Keep the pending receipt for a later retry. */
443+
}
444+
if (!cancelled) timer = setTimeout(poll, 3000)
445+
}
446+
timer = setTimeout(poll, 3000)
447+
return () => {
448+
cancelled = true
449+
clearTimeout(timer)
450+
}
451+
}, [pendingRefund])
403452

404453
useEffect(() => {
405454
const { setSession, setFailure } = useFreebuffSessionStore.getState()
@@ -525,7 +574,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
525574
// another model rejects the switch. Two cases:
526575
// - DELIBERATE pick (the explicit-pick marker was set): honor the
527576
// click — end the locked session (usually a stale row from a
528-
// crashed CLI; DELETE is keyed on user, not instance) and
577+
// crashed CLI; read its instance before deleting) and
529578
// re-claim on the requested model. The marker is consume-once,
530579
// so if the retried POST races another instance back into
531580
// model_locked we take the revert branch instead of looping.
@@ -541,10 +590,21 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
541590
const requested = getFreebuffModel(explicitPickModel).displayName
542591
let released = false
543592
try {
544-
await callFreebuffSession('DELETE', token, {
593+
const held = await callFreebuffSession('GET', token, {
545594
signal: fetchController.signal,
546595
})
547-
released = true
596+
if (
597+
!cancelled &&
598+
!fetchController.signal.aborted &&
599+
generation === restartGeneration &&
600+
held.status === 'active' &&
601+
held.model === next.currentModel
602+
) {
603+
await useFreebuffSessionStore
604+
.getState()
605+
.releaseSlot(held, fetchController.signal)
606+
released = true
607+
}
548608
} catch {
549609
// DELETE failed — fall through to the revert-with-explanation
550610
// path below rather than stranding the user mid-switch.
@@ -857,12 +917,15 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
857917
// Fire-and-forget DELETE. Only release if we actually held a slot so
858918
// we don't generate spurious DELETEs (e.g. HMR before POST completes).
859919
if (holdsLiveFreebuffSlot(current)) {
860-
callFreebuffSession('DELETE', token).catch(() => {})
920+
useFreebuffSessionStore
921+
.getState()
922+
.releaseSlot()
923+
.catch(() => {})
861924
}
862925
setSession(null)
863926
setFailure(null)
864927
}
865928
}, [])
866929

867-
return { session, failure }
930+
return { session, failure, lastRefund, refundPending: pendingRefund !== null }
868931
}

0 commit comments

Comments
 (0)