Skip to content

Commit 227e3fd

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@780e609bb790325b8c4d0383479117993062f837
1 parent fa6729b commit 227e3fd

9 files changed

Lines changed: 224 additions & 10 deletions

File tree

‎bun.lock‎

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cli/src/hooks/__tests__/use-queue-ui.test.tsx‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,68 @@ describe('useQueueUi inputBoxTitle', () => {
8181
expect(title).toContain('click to expand')
8282
})
8383
})
84+
85+
/** Renders the hook's composer placeholder for the given run/queue state. */
86+
const placeholderFor = async (params: {
87+
queuePaused?: boolean
88+
queuedMessages?: QueuedMessage[]
89+
terminalWidth?: number
90+
steersRun?: boolean
91+
}) => {
92+
let placeholder: string | undefined
93+
const terminalWidth = params.terminalWidth ?? 100
94+
const Harness = () => {
95+
placeholder = useQueueUi({
96+
queuePaused: params.queuePaused ?? false,
97+
queuedMessages: params.queuedMessages ?? [],
98+
separatorWidth: terminalWidth,
99+
terminalWidth,
100+
steersRun: params.steersRun,
101+
}).inputPlaceholder
102+
return <text>x</text>
103+
}
104+
105+
const setup = await createTestRenderer({ width: 20, height: 2 })
106+
const root = createRoot(setup.renderer)
107+
flushSync(() => root.render(<Harness />))
108+
await setup.renderOnce()
109+
flushSync(() => root.unmount())
110+
setup.renderer.destroy()
111+
return placeholder
112+
}
113+
114+
describe('useQueueUi inputPlaceholder', () => {
115+
test('idle: the ordinary prompt', async () => {
116+
expect(await placeholderFor({})).toBe(
117+
'Enter a coding task or / for commands',
118+
)
119+
})
120+
121+
test('mid-run: says Enter adds to the current task', async () => {
122+
expect(await placeholderFor({ steersRun: true })).toBe(
123+
'Add to the current task (/ for commands)',
124+
)
125+
expect(await placeholderFor({ steersRun: true, terminalWidth: 50 })).toBe(
126+
'Add to the current task',
127+
)
128+
})
129+
130+
test('mid-run with messages queued: no hint, since the next one queues too', async () => {
131+
expect(
132+
await placeholderFor({
133+
steersRun: true,
134+
queuedMessages: [message('first task')],
135+
}),
136+
).toBe('Enter a coding task or / for commands')
137+
})
138+
139+
test('a paused queue keeps its cancel hint', async () => {
140+
expect(
141+
await placeholderFor({
142+
steersRun: true,
143+
queuePaused: true,
144+
queuedMessages: [message('first task')],
145+
}),
146+
).toBe('Ctrl-C to cancel queued messages')
147+
})
148+
})

‎cli/src/hooks/use-chat-streaming.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import { useQueueControls } from './use-queue-controls'
1313
import { useQueueUi } from './use-queue-ui'
1414
import { useTimeout } from './use-timeout'
1515
import { useChatRuntime } from '../contexts/chat-runtime-context'
16+
import { useChatStore } from '../state/chat-store'
17+
import {
18+
sponsoredTurnHoldsQueue,
19+
useSponsoredRunStore,
20+
} from '../state/sponsored-run-store'
1621

1722
import type { QueuedMessage, StreamStatus } from './use-message-queue'
1823
import type { PendingAttachment } from '../types/store'
@@ -116,6 +121,15 @@ export function useChatStreaming({
116121
clearQueue,
117122
} = runtime
118123

124+
// A plain message sent now joins the running task. Not during a sponsored
125+
// turn: it has no steering mailbox, so messages wait in the queue.
126+
const isChainInProgress = useChatStore((state) => state.isChainInProgress)
127+
const sponsoredHoldsQueue = useSponsoredRunStore((state) =>
128+
sponsoredTurnHoldsQueue(state.snapshot),
129+
)
130+
const steersRun =
131+
(isStreaming || isChainInProgress) && !sponsoredHoldsQueue
132+
119133
// Queue UI
120134
const {
121135
queuedCount,
@@ -127,6 +141,7 @@ export function useChatStreaming({
127141
queuedMessages,
128142
separatorWidth,
129143
terminalWidth,
144+
steersRun,
130145
})
131146

132147
// Exit handling

‎cli/src/hooks/use-queue-ui.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ interface UseQueueUiParams {
1111
queuedMessages: QueuedMessage[]
1212
separatorWidth: number
1313
terminalWidth: number
14+
/** A run the user's next plain message will be added to (steering). */
15+
steersRun?: boolean
1416
}
1517

1618
/** Below this the title has no room for a second segment, so the expand hint
@@ -22,6 +24,7 @@ export const useQueueUi = ({
2224
queuedMessages,
2325
separatorWidth,
2426
terminalWidth,
27+
steersRun = false,
2528
}: UseQueueUiParams) => {
2629
const queuedCount = queuedMessages.length
2730
const shouldShowQueuePreview = queuedCount > 0 && !queuePaused
@@ -62,8 +65,18 @@ export const useQueueUi = ({
6265
return 'Ctrl-C to cancel queued messages'
6366
}
6467

68+
// Mid-run, Enter adds the message to the running task rather than
69+
// queueing a new one (the router's steering path). Say so: users expected
70+
// a queue and read the agent answering it as their first task skipped.
71+
// With messages already queued, a submit queues too, so no hint then.
72+
if (steersRun && queuedCount === 0) {
73+
return terminalWidth < 65
74+
? 'Add to the current task'
75+
: 'Add to the current task (/ for commands)'
76+
}
77+
6578
return base
66-
}, [queuePaused, queuedCount, terminalWidth])
79+
}, [queuePaused, queuedCount, terminalWidth, steersRun])
6780

6881
return {
6982
queuedCount,

‎packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { APICallError, RetryError } from 'ai'
2525
import { z } from 'zod/v4'
2626

2727
import { loopAgentSteps } from '../run-agent-step'
28+
import { frameSteeringText, STEERING_NOTE } from '../util/messages'
2829
import { clearAgentGeneratorCache } from '../run-programmatic-step'
2930
import {
3031
MAX_CONSECUTIVE_STREAM_RECOVERIES,
@@ -1600,9 +1601,9 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
16001601
},
16011602
})
16021603
const expected = [
1603-
[{ type: 'text', text: 'Text correction' }],
1604-
[{ type: 'text', text: 'Compare both images' }, ...images],
1605-
[images[1]],
1604+
[{ type: 'text', text: frameSteeringText('Text correction') }],
1605+
[{ type: 'text', text: frameSteeringText('Compare both images') }, ...images],
1606+
[{ type: 'text', text: frameSteeringText('') }, images[1]],
16061607
]
16071608
expect(seen).toHaveLength(2)
16081609
for (const content of expected) {
@@ -1643,6 +1644,46 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
16431644
expect((steered as { tags?: string[] }).tags).toContain('USER_PROMPT')
16441645
})
16451646

1647+
it('frames a steered message exactly once, as sent mid-turn, so the earlier request is kept', async () => {
1648+
const steerText = 'also add a README'
1649+
const seen: unknown[][] = []
1650+
let drained = false
1651+
await loopAgentSteps({
1652+
...loopAgentStepsBaseParams,
1653+
prompt: 'build the login page',
1654+
drainSteeringMessages: () => {
1655+
if (drained) return []
1656+
drained = true
1657+
// The second is already framed (a host re-delivering it): not re-wrapped.
1658+
return [steerText, frameSteeringText('and a footer')]
1659+
},
1660+
promptAiSdkStream: async function* ({ messages }) {
1661+
seen.push(structuredClone(messages))
1662+
yield createToolCallChunk('end_turn', {})
1663+
return promptSuccess(`step-${seen.length}`)
1664+
},
1665+
})
1666+
1667+
expect(seen).toHaveLength(2)
1668+
const request = JSON.stringify(seen[1])
1669+
// The original request is still there, unframed.
1670+
expect(request).toContain('build the login page')
1671+
expect(request).not.toContain(
1672+
JSON.stringify(frameSteeringText('build the login page')).slice(1, -1),
1673+
)
1674+
for (const text of [steerText, 'and a footer']) {
1675+
expect(seen[1]).toContainEqual(
1676+
expect.objectContaining({
1677+
role: 'user',
1678+
content: [{ type: 'text', text: frameSteeringText(text) }],
1679+
}),
1680+
)
1681+
}
1682+
// One note per steered message, never a nested frame.
1683+
expect(request.split(STEERING_NOTE).length - 1).toBe(2)
1684+
expect(request.split('<user_message_sent_while_working>').length - 1).toBe(2)
1685+
})
1686+
16461687
it('does not extend the turn when no steering messages arrive', async () => {
16471688
const result = await loopAgentSteps({
16481689
...loopAgentStepsBaseParams,

‎packages/agent-runtime/src/run-agent-step.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
withSystemTags as withSystemTags,
7777
buildUserMessageContent,
7878
expireMessages,
79+
frameSteeringContent,
7980
} from './util/messages'
8081
import {
8182
estimateContextTokens,
@@ -1428,15 +1429,18 @@ export async function loopAgentSteps(
14281429
// Steering: if the host fed user messages while this step ran, append them
14291430
// now (the step's LLM call + tools have completed, so history is in a clean
14301431
// state) and keep the turn going so the agent responds to them next step,
1431-
// rather than waiting for the whole turn to finish.
1432+
// rather than waiting for the whole turn to finish. Each message is
1433+
// framed as sent mid-turn: bare, the model read it as the whole new
1434+
// request and dropped the one it was working on.
14321435
const steered = await params.drainSteeringMessages?.()
14331436
if (steered?.length) {
14341437
const messages = steered.map((message) =>
14351438
userMessage({
1436-
content:
1439+
content: frameSteeringContent(
14371440
typeof message === 'string'
14381441
? buildUserMessageContent(message, undefined)
14391442
: buildUserMessageContent(message.prompt, undefined, message.content),
1443+
),
14401444
tags: ['USER_PROMPT'],
14411445
keepDuringTruncation: true,
14421446
}),

‎packages/agent-runtime/src/templates/strings.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
getProjectFileTreePrompt,
1111
getSystemInfoPrompt,
1212
} from '../system-prompt/prompts'
13-
import { parseUserMessage } from '../util/messages'
13+
import { parseUserMessage, unwrapSteeringText } from '../util/messages'
1414

1515
import type { AgentTemplate, PlaceholderValue } from './types'
1616
import type { Logger } from '@codebuff/common/types/contracts/logger'
@@ -80,7 +80,7 @@ export async function formatPrompt(
8080
const lastUserMessage = messageHistory.findLast(isUserInputMessage)
8181
const lastUserInput = lastUserMessage
8282
? (parseUserMessage(lastUserMessage.content[0].text) ??
83-
lastUserMessage.content[0].text)
83+
unwrapSteeringText(lastUserMessage.content[0].text))
8484
: undefined
8585

8686
const agentTemplate = agentState.agentType

‎packages/agent-runtime/src/util/__tests__/messages.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {
1919
messagesWithSystem,
2020
getPreviouslyReadFiles,
2121
buildUserMessageContent,
22+
frameSteeringContent,
23+
frameSteeringText,
24+
STEERING_NOTE,
25+
unwrapSteeringText,
2226
} from '../../util/messages'
2327
import * as tokenCounter from '../token-counter'
2428

@@ -733,3 +737,38 @@ describe('getPreviouslyReadFiles', () => {
733737
expect(result).toEqual([])
734738
})
735739
})
740+
741+
describe('steering framing', () => {
742+
it('wraps the text with the mid-turn note', () => {
743+
const framed = frameSteeringText('also add tests')
744+
expect(framed).toBe(
745+
`<user_message_sent_while_working>\nalso add tests\n</user_message_sent_while_working>\n\n${STEERING_NOTE}`,
746+
)
747+
})
748+
749+
it('never double-wraps', () => {
750+
const once = frameSteeringText('also add tests')
751+
expect(frameSteeringText(once)).toBe(once)
752+
})
753+
754+
it('unwraps back to exactly what the user typed', () => {
755+
const text = 'line one\n\nline two'
756+
expect(unwrapSteeringText(frameSteeringText(text))).toBe(text)
757+
expect(unwrapSteeringText('plain prompt')).toBe('plain prompt')
758+
})
759+
760+
it('frames the first text part and keeps images', () => {
761+
const image = {
762+
type: 'image' as const,
763+
image: 'Zmlyc3Q=',
764+
mediaType: 'image/png',
765+
}
766+
expect(
767+
frameSteeringContent([{ type: 'text', text: 'look' }, image]),
768+
).toEqual([{ type: 'text', text: frameSteeringText('look') }, image])
769+
expect(frameSteeringContent([image])).toEqual([
770+
{ type: 'text', text: frameSteeringText('') },
771+
image,
772+
])
773+
})
774+
})

‎packages/agent-runtime/src/util/messages.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,45 @@ export function buildUserMessageContent(
6767
]
6868
}
6969

70+
const STEERING_TAG = 'user_message_sent_while_working'
71+
const STEERING_OPEN = `<${STEERING_TAG}>`
72+
const STEERING_CLOSE = closeXml(STEERING_TAG)
73+
export const STEERING_NOTE =
74+
'The user sent this while you were working. Continue the earlier request too, unless this message changes or cancels it.'
75+
76+
/**
77+
* Frame a message the user sent mid-turn (steering). Delivered bare, it reads
78+
* as the whole new request, and models dropped the task they were doing to
79+
* answer it. Idempotent: already-framed text is returned unchanged.
80+
*/
81+
export function frameSteeringText(text: string): string {
82+
if (text.startsWith(STEERING_OPEN)) return text
83+
return `${STEERING_OPEN}\n${text}\n${STEERING_CLOSE}\n\n${STEERING_NOTE}`
84+
}
85+
86+
/** The user's own words from a framed steering message; other text unchanged. */
87+
export function unwrapSteeringText(text: string): string {
88+
if (!text.startsWith(STEERING_OPEN)) return text
89+
const end = text.lastIndexOf(STEERING_CLOSE)
90+
if (end === -1) return text
91+
return text.slice(STEERING_OPEN.length, end).replace(/^\n|\n$/g, '')
92+
}
93+
94+
/** Frame the text of a steering message's content, keeping its images. */
95+
export function frameSteeringContent(
96+
content: Array<TextPart | ImagePart>,
97+
): Array<TextPart | ImagePart> {
98+
const index = content.findIndex((part) => part.type === 'text')
99+
if (index === -1) {
100+
return [{ type: 'text', text: frameSteeringText('') }, ...content]
101+
}
102+
return content.map((part, i) =>
103+
i === index && part.type === 'text'
104+
? { ...part, text: frameSteeringText(part.text) }
105+
: part,
106+
)
107+
}
108+
70109
export function parseUserMessage(str: string): string | undefined {
71110
const match = str.match(/<user_message>(.*?)<\/user_message>/s)
72111
return match ? match[1] : undefined

0 commit comments

Comments
 (0)