Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions cli/src/commands/__tests__/btw.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, test } from 'bun:test'

import { askSideQuestion } from '../btw'

import type { ResolvedByokConnection, RunState } from '@codebuff/sdk'

const previousRun = {
output: { type: 'lastMessage', value: [] },
traceSessionId: 'trace',
} as unknown as RunState

function clientReturning(output: RunState['output']) {
const calls: Record<string, unknown>[] = []
const clientArgs: Record<string, unknown>[] = []
const getClient = (async (options: Record<string, unknown> = {}) => {
clientArgs.push(options)
return {
run: async (runOptions: Record<string, unknown>) => {
calls.push(runOptions)
return { output, traceSessionId: 'trace' }
},
}
}) as unknown as Parameters<typeof askSideQuestion>[0]['getClient']
return { calls, clientArgs, getClient }
}

describe('askSideQuestion', () => {
test('asks once, with no tools, off the current run', async () => {
const { calls, getClient } = clientReturning({
type: 'lastMessage',
value: [
{
role: 'assistant',
content: [{ type: 'text', text: 'Because the flag defaults to off.' }],
},
],
})

const answer = await askSideQuestion({
question: 'Why is X off?',
previousRun,
getClient,
})

expect(answer).toBe('Because the flag defaults to off.')
expect(calls).toHaveLength(1)
expect(calls[0]?.prompt).toBe('Why is X off?')
expect(calls[0]?.previousRun).toBe(previousRun)
expect(calls[0]?.maxAgentSteps).toBe(1)
expect(calls[0]?.costMode).toBe('free')
expect((calls[0]?.agent as { toolNames: string[] }).toolNames).toEqual([])
})

test('runs on the selected BYOK connection, off the free-mode path', async () => {
const { calls, clientArgs, getClient } = clientReturning({
type: 'lastMessage',
value: [
{
role: 'assistant',
content: [{ type: 'text', text: 'Because you asked.' }],
},
],
})
const byok = {
provider: 'openai-compatible',
model: 'mine/model',
} as ResolvedByokConnection

await askSideQuestion({ question: 'Why?', previousRun, byok, getClient })

expect(clientArgs[0]).toEqual({ byok })
expect(calls[0]?.costMode).toBe('normal')
})

test('surfaces a run error', async () => {
const { getClient } = clientReturning({
type: 'error',
message: 'Out of credits',
})

await expect(
askSideQuestion({ question: 'Anything?', previousRun, getClient }),
).rejects.toThrow('Out of credits')
})

test('fails when the run returns no text', async () => {
const { getClient } = clientReturning({ type: 'lastMessage', value: [] })

await expect(
askSideQuestion({ question: 'Anything?', previousRun, getClient }),
).rejects.toThrow('No answer came back.')
})

test('asks the user to log in when there is no client', async () => {
const getClient = (async () =>
null) as unknown as Parameters<typeof askSideQuestion>[0]['getClient']

await expect(
askSideQuestion({ question: 'Anything?', previousRun, getClient }),
).rejects.toThrow('/login')
})
})
6 changes: 6 additions & 0 deletions cli/src/commands/__tests__/freebuff-command-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ describe('freebuff command aliases', () => {
assert.equal(SLASH_COMMANDS.some(cmd => cmd.id === name), !isFree)
}

// /btw builds its side-question agent from the freebuff catalog, so
// Codebuff must not offer it at all.
const freeMode = process.env.FREEBUFF_MODE === 'true'
assert.equal(SLASH_COMMANDS.some(cmd => cmd.id === 'btw'), freeMode)
assert.equal(findCommand('btw') !== undefined, freeMode)

if (process.env.FREEBUFF_MODE !== 'true') {
for (const name of ['reasoning', 'effort', 'think']) {
assert.equal(findCommand(name), undefined)
Expand Down
84 changes: 84 additions & 0 deletions cli/src/commands/btw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { compiledFreebuffModelIdOfRow } from '@codebuff/common/util/freebuff-catalog-compiled-model'

import { getFreebuffModelDirectory } from '../state/freebuff-catalog-store'
import { getSelectedFreebuffModel } from '../state/freebuff-model-store'
import { getCodebuffClient } from '../utils/codebuff-client'
import { extractSpawnAgentResultContent } from '../utils/message-block-helpers'

import type { AgentDefinition, ResolvedByokConnection, RunState } from '@codebuff/sdk'

export const BTW_USAGE = 'Usage: /btw <question>'

/**
* `/btw` is Claude Code's side question: one answer that sees the conversation,
* takes no actions, and never becomes part of the history. The constraints are
* in the system prompt because the agent has no tools to enforce them.
*/
const SIDE_QUESTION_SYSTEM_PROMPT = [
'You are answering a side question the user asked in the middle of a task.',
'Answer directly in a single response, using only what this conversation already shows.',
'You have no tools: do not read files, run commands, search, or change anything, and never promise to try, check, or look something up.',
'When the conversation does not contain the answer, say what is missing.',
].join('\n')

/**
* The model the current selection runs, in the form an inline agent may name:
* a catalog row runs the server-side handle, which only its own root admits, so
* a plain definition has to name the compiled model id underneath it.
*/
function sideQuestionAgent(): AgentDefinition {
const selection = getSelectedFreebuffModel()
const row = getFreebuffModelDirectory().row(selection)
const model =
row?.key === selection ? compiledFreebuffModelIdOfRow(row) : selection
return {
id: 'freebuff-side-question',
displayName: 'Side question',
model: model as AgentDefinition['model'],
systemPrompt: SIDE_QUESTION_SYSTEM_PROMPT,
toolNames: [],
}
}

/**
* Runs the question against `previousRun` and returns the answer. The returned
* run state is dropped: forking from the session means the answer is not
* appended to the conversation the main agent keeps working in.
*/
export async function askSideQuestion({
question,
previousRun,
byok,
getClient = getCodebuffClient,
}: {
question: string
previousRun: RunState
/** The BYOK connection the session runs on, if the user selected one. */
byok?: ResolvedByokConnection
getClient?: typeof getCodebuffClient
}): Promise<string> {
const client = await getClient({ ...(byok ? { byok } : {}) })
if (!client) {
throw new Error('Not logged in. Run /login first.')
}

const state = await client.run({
agent: sideQuestionAgent(),
prompt: question,
previousRun,
// One model call and no tool round trip.
maxAgentSteps: 1,
// BYOK skips the free-mode admission path, same as a normal send.
costMode: byok ? 'normal' : 'free',
})

if (state.output.type === 'error') {
throw new Error(state.output.message)
}
const { content, hasError } = extractSpawnAgentResultContent(state.output)
const answer = content.trim()
if (hasError || !answer) {
throw new Error('No answer came back.')
}
return answer
}
59 changes: 59 additions & 0 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
handleProposalsOff,
} from './ads'
import { handleDealsCommand } from './deals'
import { askSideQuestion, BTW_USAGE } from './btw'
import { resolveByokConnection, selectedByokConnection } from '../utils/byok'
import { handleCopyConversationCommand } from './copy-conversation'
import { handleExportConversationCommand } from './export-conversation'
import { handleHelpCommand } from './help'
Expand Down Expand Up @@ -225,6 +227,9 @@ const FREEBUFF_ONLY_COMMANDS = new Set([
// (isFreebuffOriginatedRequest). On Codebuff the command would take a value
// and silently drop it.
'reasoning',
// Side questions fork the freebuff root agent, which /btw builds from the
// freebuff model catalog.
'btw',
])

const ALL_COMMANDS: CommandDefinition[] = [
Expand Down Expand Up @@ -810,6 +815,60 @@ const ALL_COMMANDS: CommandDefinition[] = [
openFreebuffReasoningPicker()
},
}),
defineCommandWithArgs({
name: 'btw',
handler: async (params, args) => {
params.saveToHistory(params.inputValue.trim())
clearInput(params)

const question = args.trim()
if (!question) {
params.setMessages((prev) => [...prev, getSystemMessage(BTW_USAGE)])
return
}

// Fork the last settled turn, not a transcript copy: the side answer
// sees what the agent sees, and the run it produces is discarded.
// runState is only written at end of turn, so a question asked mid-turn
// forks the session as it stood before that turn's newest messages.
const previousRun = useChatStore.getState().runState
if (!previousRun) {
params.setMessages((prev) => [
...prev,
getSystemMessage(
'Nothing to answer from yet. Send a message first, then ask /btw.',
),
])
return
}

const pending = getSystemMessage(`/btw ${question}`)
params.setMessages((prev) => [...prev, pending])
const settle = (content: string) =>
params.setMessages((prev) =>
prev.map((message) =>
message.id === pending.id ? { ...message, content } : message,
),
)

try {
const selectedByok = selectedByokConnection()
const byok = selectedByok
? await resolveByokConnection(selectedByok)
: undefined
const answer = await askSideQuestion({
question,
previousRun,
byok,
})
settle(`/btw ${question}\n\n${answer}`)
} catch (error) {
settle(
`❌ /btw failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
},
}),
defineCommand({
name: 'end-session',
handler: (params) => {
Expand Down
6 changes: 6 additions & 0 deletions cli/src/data/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const FREEBUFF_ONLY_COMMAND_IDS = new Set([
'reasoning',
'dashboard',
'deals',
'btw',
])

const ALL_SLASH_COMMANDS: SlashCommand[] = [
Expand All @@ -70,6 +71,11 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
description: 'Show local CLI resource usage and terminal tool process IDs',
aliases: ['diag', 'processes'],
},
{
id: 'btw',
label: 'btw',
description: 'Ask a quick side question without adding it to the conversation',
},
{
id: 'ads:enable',
label: 'ads:enable',
Expand Down
Loading