Skip to content
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"permissions": {
"defaultMode": "auto"
}
}
37 changes: 26 additions & 11 deletions cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,17 +285,6 @@ export const App = ({
)
}

// Render chat history screen when requested
if (showChatHistory) {
return (
<ChatHistoryScreen
onSelectChat={handleResumeChat}
onCancel={closeChatHistory}
onNewChat={handleNewChat}
/>
)
}

// Use key to force remount when resuming a different chat from history
const chatKey = resumeChatId ?? 'current'

Expand All @@ -316,6 +305,10 @@ export const App = ({
initialMode={initialMode}
gitRoot={gitRoot}
onSwitchToGitRoot={handleSwitchToGitRoot}
showChatHistory={showChatHistory}
onSelectChat={handleResumeChat}
onCancelChatHistory={closeChatHistory}
onNewChat={handleNewChat}
/>
)
}
Expand All @@ -336,6 +329,10 @@ interface AuthedSurfaceProps {
initialMode: AgentMode | undefined
gitRoot: string | null | undefined
onSwitchToGitRoot: () => void
showChatHistory: boolean
onSelectChat: (chatId: string) => void
onCancelChatHistory: () => void
onNewChat: () => void
}

/**
Expand All @@ -359,6 +356,10 @@ const AuthedSurface = ({
initialMode,
gitRoot,
onSwitchToGitRoot,
showChatHistory,
onSelectChat,
onCancelChatHistory,
onNewChat,
}: AuthedSurfaceProps) => {
const { session, error: sessionError } = useFreebuffSession()

Expand Down Expand Up @@ -388,6 +389,20 @@ const AuthedSurface = ({
return <WaitingRoomScreen session={session} error={sessionError} />
}

// Chat history renders inside AuthedSurface so the freebuff session stays
// mounted while the user browses history. Unmounting this surface would
// DELETE the session row and drop the user back into the waiting room on
// return.
if (showChatHistory) {
return (
<ChatHistoryScreen
onSelectChat={onSelectChat}
onCancel={onCancelChatHistory}
onNewChat={onNewChat}
/>
)
}

return (
<Chat
key={chatKey}
Expand Down
10 changes: 6 additions & 4 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1473,15 +1473,17 @@ export const Chat = ({
)}

{reviewMode ? (
// Review takes precedence over the session-ended banner: during the
// grace window the agent may still be asking to run tools, and
// those approvals must be reachable for the run to finish.
// Review and ask_user take precedence over the session-ended banner:
// during the grace window the agent may still be asking to run tools
// or asking the user a question, and those approvals/answers must be
// reachable for the run to finish — otherwise the agent hangs
// waiting for input that can never be given.
<ReviewScreen
onSelectOption={handleReviewOptionSelect}
onCustom={handleReviewCustom}
onCancel={handleCloseReviewScreen}
/>
) : isFreebuffSessionOver ? (
) : isFreebuffSessionOver && !askUserState ? (
<SessionEndedBanner
isStreaming={isStreaming || isWaitingForResponse}
/>
Expand Down
14 changes: 10 additions & 4 deletions cli/src/hooks/helpers/send-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,10 +510,16 @@ function handleFreebuffGateError(
switch (kind) {
case 'session_expired':
case 'waiting_room_required':
// Our seat is gone mid-chat. Flip to `ended` instead of auto re-queuing:
// the Chat surface stays mounted so any in-flight agent work can finish
// under the server-side grace period, and the session-ended banner
// prompts the user to press Enter when they're ready to rejoin.
// Our seat is gone mid-chat. Finalize the AI message so its streaming
// indicator stops — otherwise `isComplete` stays false and the message
// keeps rendering a blinking cursor forever, making the user think the
// agent is still working even though the SessionEndedBanner is visible
// and actionable. Also disposes the batched-updater flush interval.
updater.markComplete()
// Flip to `ended` instead of auto re-queuing: the Chat surface stays
// mounted so any in-flight agent work can finish under the server-side
// grace period, and the session-ended banner prompts the user to press
// Enter when they're ready to rejoin.
markFreebuffSessionEnded()
return
case 'waiting_room_queued':
Expand Down
2 changes: 1 addition & 1 deletion docs/error-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Used for errors that the client needs to identify programmatically:

| Status | `error` code | Example `message` |
|--------|-------------|-------------------|
| 403 | `account_suspended` | `"Your account has been suspended due to billing issues. Please contact support@codebuff.com to resolve this."` |
| 403 | `account_suspended` | `"Your account has been suspended. Please contact support@codebuff.com if you did not expect this."` |
| 403 | `free_mode_unavailable` | `"Free mode is not available in your country."` (Freebuff: `"Freebuff is not available in your country."`) |
| 429 | `rate_limit_exceeded` | `"Subscription weekly limit reached. Your limit resets in 2 hours. Enable 'Continue with credits' in the CLI to use a-la-carte credits."` |

Expand Down
3 changes: 2 additions & 1 deletion packages/agent-runtime/src/__tests__/main-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ describe('mainPrompt', () => {
it('should update consecutiveAssistantMessages when new prompt is received', async () => {
const sessionState = getInitialSessionState(mockFileContext)
sessionState.mainAgentState.stepsRemaining = 12
const initialStepsRemaining = sessionState.mainAgentState.stepsRemaining

const action = {
type: 'prompt' as const,
Expand All @@ -394,7 +395,7 @@ describe('mainPrompt', () => {

// When there's a new prompt, consecutiveAssistantMessages should be set to 1
expect(newSessionState.mainAgentState.stepsRemaining).toBe(
sessionState.mainAgentState.stepsRemaining - 1,
initialStepsRemaining - 1,
)
})

Expand Down
30 changes: 22 additions & 8 deletions packages/agent-runtime/src/run-agent-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,17 @@ export const runAgentStep = async (
}
}

/**
* Runs the agent loop.
*
* IMPORTANT: This function mutates `params.agentState` in place throughout the
* run (not just at return time). Fields like `messageHistory`, `systemPrompt`,
* `toolDefinitions`, `creditsUsed`, and `output` are updated as work progresses
* so that callers holding a reference to the same object (e.g. the SDK's
* `sessionState.mainAgentState`) see in-progress work immediately — which
* matters when an error is thrown mid-run and the normal return path is
* skipped.
*/
export async function loopAgentSteps(
params: {
addAgentStep: AddAgentStepFn
Expand Down Expand Up @@ -800,12 +811,13 @@ export async function loopAgentSteps(
return cachedAdditionalToolDefinitions
}

let currentAgentState: AgentState = {
...initialAgentState,
messageHistory: initialMessages,
systemPrompt: system,
toolDefinitions,
}
// Mutate initialAgentState so that in-progress work propagates back to the
// caller's shared reference (e.g. SDK's sessionState.mainAgentState) even if
// an error is thrown before we return.
initialAgentState.messageHistory = initialMessages
initialAgentState.systemPrompt = system
initialAgentState.toolDefinitions = toolDefinitions
let currentAgentState: AgentState = initialAgentState

// Convert tool definitions to Anthropic format for accurate token counting
// Tool definitions are stored as { [name]: { description, inputSchema } }
Expand Down Expand Up @@ -908,7 +920,8 @@ export async function loopAgentSteps(
} = programmaticResult
n = generateN

currentAgentState = programmaticAgentState
Object.assign(initialAgentState, programmaticAgentState)
currentAgentState = initialAgentState
totalSteps = stepNumber

shouldEndTurn = endTurn
Expand Down Expand Up @@ -989,7 +1002,8 @@ export async function loopAgentSteps(
logger.error('No runId found for agent state after finishing agent run')
}

currentAgentState = newAgentState
Object.assign(initialAgentState, newAgentState)
currentAgentState = initialAgentState
shouldEndTurn = llmShouldEndTurn
nResponses = generatedResponses

Expand Down
Loading
Loading