Skip to content

Commit 7e07b1a

Browse files
jahoomagithub-actions[bot]YoungSxaether-agent[bot]CodebuffAI
authored
Freebuff waiting room (#509)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Shangxin <shangxin@outlook.com> Co-authored-by: aether-agent[bot] <258877100+aether-agent[bot]@users.noreply.github.com> Co-authored-by: CodebuffAI <189203002+CodebuffAI@users.noreply.github.com> Co-authored-by: 陈家名 <chenjiaming@kezaihui.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7f24658 commit 7e07b1a

File tree

66 files changed

+6939
-1561
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+6939
-1561
lines changed

agents/__tests__/basher.test.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,11 @@ describe('commander agent', () => {
5959
expect(schema?.params?.required).not.toContain('timeout_seconds')
6060
})
6161

62-
test('has optional rawOutput parameter', () => {
62+
test('has optional what_to_summarize parameter', () => {
6363
const schema = commander.inputSchema
64-
const rawOutputProp = schema?.params?.properties?.rawOutput
65-
expect(rawOutputProp && typeof rawOutputProp === 'object' && 'type' in rawOutputProp && rawOutputProp.type).toBe('boolean')
66-
expect(schema?.params?.required).not.toContain('rawOutput')
67-
})
68-
69-
test('has prompt parameter', () => {
70-
expect(commander.inputSchema?.prompt?.type).toBe('string')
64+
const summarizeProp = schema?.params?.properties?.what_to_summarize
65+
expect(summarizeProp && typeof summarizeProp === 'object' && 'type' in summarizeProp && summarizeProp.type).toBe('string')
66+
expect(schema?.params?.required).not.toContain('what_to_summarize')
7167
})
7268
})
7369

@@ -149,7 +145,7 @@ describe('commander agent', () => {
149145
})
150146
})
151147

152-
test('yields set_output with raw result when rawOutput is true', () => {
148+
test('yields set_output with raw result when what_to_summarize is not provided', () => {
153149
const mockAgentState = createMockAgentState()
154150
const mockLogger = {
155151
debug: () => {},
@@ -161,7 +157,7 @@ describe('commander agent', () => {
161157
const generator = commander.handleSteps!({
162158
agentState: mockAgentState,
163159
logger: mockLogger as any,
164-
params: { command: 'echo hello', rawOutput: true },
160+
params: { command: 'echo hello' },
165161
})
166162

167163
// First yield is the command
@@ -190,7 +186,7 @@ describe('commander agent', () => {
190186
expect(final.done).toBe(true)
191187
})
192188

193-
test('yields STEP for model analysis when rawOutput is false', () => {
189+
test('yields STEP for model analysis when what_to_summarize is provided', () => {
194190
const mockAgentState = createMockAgentState()
195191
const mockLogger = {
196192
debug: () => {},
@@ -202,7 +198,7 @@ describe('commander agent', () => {
202198
const generator = commander.handleSteps!({
203199
agentState: mockAgentState,
204200
logger: mockLogger as any,
205-
params: { command: 'ls -la', rawOutput: false },
201+
params: { command: 'ls -la', what_to_summarize: 'list of files' },
206202
})
207203

208204
// First yield is the command
@@ -233,7 +229,7 @@ describe('commander agent', () => {
233229
const generator = commander.handleSteps!({
234230
agentState: mockAgentState,
235231
logger: mockLogger as any,
236-
params: { command: 'echo test', rawOutput: true },
232+
params: { command: 'echo test' },
237233
})
238234

239235
// First yield is the command
@@ -266,7 +262,7 @@ describe('commander agent', () => {
266262
const generator = commander.handleSteps!({
267263
agentState: mockAgentState,
268264
logger: mockLogger as any,
269-
params: { command: 'echo test', rawOutput: true },
265+
params: { command: 'echo test' },
270266
})
271267

272268
// First yield is the command

bunfig.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ linkWorkspacePackages = true
77
[test]
88
# Exclude test repositories, integration tests, and Playwright e2e tests from test execution by default
99
exclude = ["evals/test-repos/**", "**/*.integration.test.*", "web/src/__tests__/e2e/**"]
10-
preload = ["./sdk/test/setup-env.ts", "./test/setup-bigquery-mocks.ts", "./web/test/setup-globals.ts"]
10+
preload = ["./test/setup-scm-loader.ts", "./sdk/test/setup-env.ts", "./test/setup-bigquery-mocks.ts", "./web/test/setup-globals.ts"]

cli/src/app.tsx

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import { useShallow } from 'zustand/react/shallow'
44

55
import { Chat } from './chat'
66
import { ChatHistoryScreen } from './components/chat-history-screen'
7+
import { FreebuffSupersededScreen } from './components/freebuff-superseded-screen'
78
import { LoginModal } from './components/login-modal'
89
import { ProjectPickerScreen } from './components/project-picker-screen'
910
import { TerminalLink } from './components/terminal-link'
11+
import { WaitingRoomScreen } from './components/waiting-room-screen'
1012
import { useAuthQuery } from './hooks/use-auth-query'
1113
import { useAuthState } from './hooks/use-auth-state'
14+
import { useFreebuffSession } from './hooks/use-freebuff-session'
1215
import { useLogo } from './hooks/use-logo'
1316
import { useSheenAnimation } from './hooks/use-sheen-animation'
1417
import { useTerminalDimensions } from './hooks/use-terminal-dimensions'
@@ -297,8 +300,8 @@ export const App = ({
297300
const chatKey = resumeChatId ?? 'current'
298301

299302
return (
300-
<Chat
301-
key={chatKey}
303+
<AuthedSurface
304+
chatKey={chatKey}
302305
headerContent={headerContent}
303306
initialPrompt={initialPrompt}
304307
agentId={agentId}
@@ -316,3 +319,93 @@ export const App = ({
316319
/>
317320
)
318321
}
322+
323+
interface AuthedSurfaceProps {
324+
chatKey: string
325+
headerContent: React.ReactNode
326+
initialPrompt: string | null
327+
agentId?: string
328+
fileTree: FileTreeNode[]
329+
inputRef: React.MutableRefObject<MultilineInputHandle | null>
330+
setIsAuthenticated: React.Dispatch<React.SetStateAction<boolean | null>>
331+
setUser: React.Dispatch<React.SetStateAction<import('./utils/auth').User | null>>
332+
logoutMutation: ReturnType<typeof useAuthState>['logoutMutation']
333+
continueChat: boolean
334+
continueChatId: string | undefined
335+
authStatus: AuthStatus
336+
initialMode: AgentMode | undefined
337+
gitRoot: string | null | undefined
338+
onSwitchToGitRoot: () => void
339+
}
340+
341+
/**
342+
* Rendered only after auth is confirmed. Owns the freebuff waiting-room gate
343+
* so `useFreebuffSession` runs exactly once per authed session (not before
344+
* we have a token).
345+
*/
346+
const AuthedSurface = ({
347+
chatKey,
348+
headerContent,
349+
initialPrompt,
350+
agentId,
351+
fileTree,
352+
inputRef,
353+
setIsAuthenticated,
354+
setUser,
355+
logoutMutation,
356+
continueChat,
357+
continueChatId,
358+
authStatus,
359+
initialMode,
360+
gitRoot,
361+
onSwitchToGitRoot,
362+
}: AuthedSurfaceProps) => {
363+
const { session, error: sessionError } = useFreebuffSession()
364+
365+
// Terminal state: a 409 from the gate means another CLI rotated our
366+
// instance id. Show a dedicated screen and stop polling — don't fall back
367+
// into the waiting room, which would look like normal queued progress.
368+
if (IS_FREEBUFF && session?.status === 'superseded') {
369+
return <FreebuffSupersededScreen />
370+
}
371+
372+
// Route every non-admitted state through the waiting room:
373+
// null → initial POST in flight
374+
// 'queued' → waiting our turn
375+
// 'none' → server lost our row; hook is about to re-POST
376+
// Falling through to <Chat> on 'none' would leave the user unable to send
377+
// any free-mode request until the next poll cycle.
378+
//
379+
// 'ended' deliberately falls through to <Chat>: the agent may still be
380+
// finishing work under the server-side grace period, and the chat surface
381+
// itself swaps the input box for the session-ended banner.
382+
if (
383+
IS_FREEBUFF &&
384+
(session === null ||
385+
session.status === 'queued' ||
386+
session.status === 'none')
387+
) {
388+
return <WaitingRoomScreen session={session} error={sessionError} />
389+
}
390+
391+
return (
392+
<Chat
393+
key={chatKey}
394+
headerContent={headerContent}
395+
initialPrompt={initialPrompt}
396+
agentId={agentId}
397+
fileTree={fileTree}
398+
inputRef={inputRef}
399+
setIsAuthenticated={setIsAuthenticated}
400+
setUser={setUser}
401+
logoutMutation={logoutMutation}
402+
continueChat={continueChat}
403+
continueChatId={continueChatId}
404+
authStatus={authStatus}
405+
initialMode={initialMode}
406+
gitRoot={gitRoot}
407+
onSwitchToGitRoot={onSwitchToGitRoot}
408+
freebuffSession={session}
409+
/>
410+
)
411+
}

cli/src/chat.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ReviewScreen } from './components/review-screen'
2121
import { MessageWithAgents } from './components/message-with-agents'
2222
import { areCreditsRestored } from './components/out-of-credits-banner'
2323
import { PendingBashMessage } from './components/pending-bash-message'
24+
import { SessionEndedBanner } from './components/session-ended-banner'
2425
import { StatusBar } from './components/status-bar'
2526
import { TopBanner } from './components/top-banner'
2627
import { getSlashCommandsWithSkills } from './data/slash-commands'
@@ -83,6 +84,7 @@ import { computeInputLayoutMetrics } from './utils/text-layout'
8384
import type { CommandResult } from './commands/command-registry'
8485
import type { MultilineInputHandle } from './components/multiline-input'
8586
import type { MatchedSlashCommand } from './hooks/use-suggestion-engine'
87+
import type { FreebuffSessionResponse } from './types/freebuff-session'
8688
import type { User } from './utils/auth'
8789
import type { AgentMode } from './utils/constants'
8890
import type { FileTreeNode } from '@codebuff/common/util/file'
@@ -105,6 +107,7 @@ export const Chat = ({
105107
initialMode,
106108
gitRoot,
107109
onSwitchToGitRoot,
110+
freebuffSession,
108111
}: {
109112
headerContent: React.ReactNode
110113
initialPrompt: string | null
@@ -120,6 +123,7 @@ export const Chat = ({
120123
initialMode?: AgentMode
121124
gitRoot?: string | null
122125
onSwitchToGitRoot?: () => void
126+
freebuffSession: FreebuffSessionResponse | null
123127
}) => {
124128
const [forceFileOnlyMentions, setForceFileOnlyMentions] = useState(false)
125129

@@ -1337,9 +1341,16 @@ export const Chat = ({
13371341
return ` ${segments.join(' ')} `
13381342
}, [queuePreviewTitle, pausedQueueText])
13391343

1344+
const hasActiveFreebuffSession =
1345+
IS_FREEBUFF && freebuffSession?.status === 'active'
1346+
const isFreebuffSessionOver =
1347+
IS_FREEBUFF && freebuffSession?.status === 'ended'
13401348
const shouldShowStatusLine =
13411349
!feedbackMode &&
1342-
(hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom)
1350+
(hasStatusIndicatorContent ||
1351+
shouldShowQueuePreview ||
1352+
!isAtBottom ||
1353+
hasActiveFreebuffSession)
13431354

13441355
// Track mouse movement for ad activity (throttled)
13451356
const lastMouseActivityRef = useRef<number>(0)
@@ -1442,6 +1453,7 @@ export const Chat = ({
14421453
scrollToLatest={scrollToLatest}
14431454
statusIndicatorState={statusIndicatorState}
14441455
onStop={chatKeyboardHandlers.onInterruptStream}
1456+
freebuffSession={freebuffSession}
14451457
/>
14461458
)}
14471459

@@ -1461,11 +1473,18 @@ export const Chat = ({
14611473
)}
14621474

14631475
{reviewMode ? (
1476+
// Review takes precedence over the session-ended banner: during the
1477+
// grace window the agent may still be asking to run tools, and
1478+
// those approvals must be reachable for the run to finish.
14641479
<ReviewScreen
14651480
onSelectOption={handleReviewOptionSelect}
14661481
onCustom={handleReviewCustom}
14671482
onCancel={handleCloseReviewScreen}
14681483
/>
1484+
) : isFreebuffSessionOver ? (
1485+
<SessionEndedBanner
1486+
isStreaming={isStreaming || isWaitingForResponse}
1487+
/>
14691488
) : (
14701489
<ChatInputBar
14711490
inputValue={inputValue}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import React from 'react'
3+
4+
import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit'
5+
import { useLogo } from '../hooks/use-logo'
6+
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
7+
import { useTheme } from '../hooks/use-theme'
8+
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
9+
10+
/**
11+
* Terminal state shown after a 409 session_superseded response. Another CLI on
12+
* the same account rotated our instance id and we've stopped polling — the
13+
* user needs to close the other instance and restart.
14+
*/
15+
export const FreebuffSupersededScreen: React.FC = () => {
16+
const theme = useTheme()
17+
const { contentMaxWidth } = useTerminalDimensions()
18+
const blockColor = getLogoBlockColor(theme.name)
19+
const accentColor = getLogoAccentColor(theme.name)
20+
const { component: logoComponent } = useLogo({
21+
availableWidth: contentMaxWidth,
22+
accentColor,
23+
blockColor,
24+
})
25+
26+
useFreebuffCtrlCExit()
27+
28+
return (
29+
<box
30+
style={{
31+
width: '100%',
32+
height: '100%',
33+
flexDirection: 'column',
34+
backgroundColor: theme.background,
35+
alignItems: 'center',
36+
justifyContent: 'center',
37+
paddingLeft: 2,
38+
paddingRight: 2,
39+
gap: 1,
40+
}}
41+
>
42+
<box style={{ marginBottom: 1 }}>{logoComponent}</box>
43+
<text
44+
style={{ fg: theme.foreground, marginBottom: 1 }}
45+
attributes={TextAttributes.BOLD}
46+
>
47+
Another freebuff instance took over this account.
48+
</text>
49+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
50+
Only one CLI per account can be active at a time.
51+
</text>
52+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
53+
Close the other instance, then restart freebuff here.
54+
</text>
55+
<box style={{ marginTop: 1 }}>
56+
<text style={{ fg: theme.muted }}>
57+
Press <span fg={theme.primary}>Ctrl+C</span> to exit.
58+
</text>
59+
</box>
60+
</box>
61+
)
62+
}

0 commit comments

Comments
 (0)