Skip to content

Commit 28663a3

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@849880d40c48f8a0afb7ce0ee525dd1aba3f84dc
1 parent 31e6e83 commit 28663a3

13 files changed

Lines changed: 488 additions & 37 deletions

File tree

‎agents/__tests__/base3.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,12 @@ describe('base3 CLI roots', () => {
6262
for (const agent of CLI_ROOTS) {
6363
// Windowed reads + the 100-entry glob cap + search-first tool wording.
6464
expect(agent.windowedFileReads).toBe(true)
65-
// Mechanical compaction in-process, instead of spawning context-pruner.
66-
expect(agent.compactContext).toBe(true)
65+
// Mechanical compaction in-process, instead of spawning context-pruner —
66+
// with the 30-min idle trigger pushed out to an hour, so a coffee break
67+
// never rewrites the history (see base3.ts for the cost trade).
68+
expect(agent.compactContext).toEqual({
69+
cacheExpiryMs: 60 * 60 * 1000,
70+
})
6771
// Single loop: no subagents at all, which is what the harness IS.
6872
expect(agent.spawnableAgents ?? []).toEqual([])
6973
expect(agent.toolNames ?? []).not.toContain('spawn_agents')

‎agents/base3.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,17 @@ export function createBase3(
3131
outputMode: 'last_message',
3232
includeMessageHistory: true,
3333
windowedFileReads: true,
34-
compactContext: true,
34+
// One hour, not the 30-minute default and not base-chat's 24h. The idle
35+
// trigger is a product knob, not a TTL tracker: per-lane cache TTLs range
36+
// from ~60s (Merge) to hours (DeepSeek), and compaction never PREVENTS the
37+
// cold prefill after an idle gap — it only shrinks it (full history -> the
38+
// ~70k summary budgets), at the price of dropped tool results and truncated
39+
// prose. Under an hour is coffee-break territory, where that trade reads as
40+
// "the model forgot everything" (a top user complaint) and DeepSeek's cache
41+
// may still be warm, making the rewrite a pure loss. Past an hour every
42+
// lane is cold and the return is a new working session, so the smaller
43+
// prefill wins.
44+
compactContext: { cacheExpiryMs: 60 * 60 * 1000 },
3545
toolNames: [
3646
'read_files',
3747
'str_replace',

‎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.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, expect, mock, test } from 'bun:test'
2+
3+
import { exportConversation } from '../export-conversation'
4+
import { IS_FREEBUFF } from '../../utils/constants'
5+
6+
import type { ExportDeps } from '../export-conversation'
7+
import type { ChatMessage, ContentBlock } from '../../types/chat'
8+
9+
// The default filename prefix follows the product branding, like the
10+
// transcript header in serializeConversation.
11+
const PRODUCT = IS_FREEBUFF ? 'freebuff' : 'codebuff'
12+
13+
const msg = (
14+
variant: ChatMessage['variant'],
15+
opts: { content?: string; blocks?: ContentBlock[] } = {},
16+
): ChatMessage => ({
17+
id: `${variant}-${opts.content ?? 'x'}`,
18+
variant,
19+
content: opts.content ?? '',
20+
blocks: opts.blocks,
21+
timestamp: '2026-06-25T00:00:00.000Z',
22+
})
23+
24+
const makeDeps = () => {
25+
const writes: Array<{ filePath: string; data: string }> = []
26+
const deps: ExportDeps = {
27+
getProjectRoot: () => '/project',
28+
getCurrentChatId: () => '2026-06-25T00-00-00.000Z',
29+
fileExists: () => false,
30+
writeFile: async (filePath, data) => {
31+
writes.push({ filePath, data })
32+
},
33+
}
34+
return { deps, writes }
35+
}
36+
37+
describe('exportConversation', () => {
38+
test('defaults to <product>-chat-<chatId>.md in the project root', async () => {
39+
const { deps, writes } = makeDeps()
40+
const result = await exportConversation(
41+
[msg('user', { content: 'hi' })],
42+
'',
43+
deps,
44+
)
45+
46+
expect(result).toMatchObject({
47+
ok: true,
48+
filePath: `/project/${PRODUCT}-chat-2026-06-25T00-00-00.000Z.md`,
49+
format: 'markdown',
50+
})
51+
expect(writes).toHaveLength(1)
52+
expect(writes[0].data).toContain('## User')
53+
expect(writes[0].data).toContain('hi')
54+
})
55+
56+
test('resolves a relative filename against the project root', async () => {
57+
const { deps, writes } = makeDeps()
58+
const result = await exportConversation(
59+
[msg('user', { content: 'hi' })],
60+
' notes/chat.md ',
61+
deps,
62+
)
63+
expect(result).toMatchObject({
64+
ok: true,
65+
filePath: '/project/notes/chat.md',
66+
})
67+
expect(writes[0].filePath).toBe('/project/notes/chat.md')
68+
})
69+
70+
test('refuses a path that escapes the project root', async () => {
71+
const { deps, writes } = makeDeps()
72+
// path.resolve honors both of these; without the containment check they
73+
// reach anything on disk.
74+
for (const escape of ['../outside.md', '/etc/hosts']) {
75+
const result = await exportConversation(
76+
[msg('user', { content: 'hi' })],
77+
escape,
78+
deps,
79+
)
80+
expect(result).toMatchObject({ ok: false })
81+
if (!result.ok) expect(result.error).toContain('project root')
82+
}
83+
expect(writes).toHaveLength(0)
84+
})
85+
86+
test('refuses to overwrite an existing file', async () => {
87+
const { deps, writes } = makeDeps()
88+
deps.fileExists = () => true
89+
// The atomic write renames over the target, so '/export README.md' would
90+
// otherwise destroy the file with no prompt and no backup.
91+
const result = await exportConversation(
92+
[msg('user', { content: 'hi' })],
93+
'README.md',
94+
deps,
95+
)
96+
expect(result).toMatchObject({ ok: false })
97+
if (!result.ok) expect(result.error).toContain('already exists')
98+
expect(writes).toHaveLength(0)
99+
})
100+
101+
test('a .json filename exports the raw messages as parseable JSON', async () => {
102+
const { deps, writes } = makeDeps()
103+
const messages = [msg('user', { content: 'hello json' })]
104+
const result = await exportConversation(messages, 'out.json', deps)
105+
106+
expect(result).toMatchObject({ ok: true, format: 'json' })
107+
const parsed = JSON.parse(writes[0].data)
108+
expect(parsed).toHaveLength(1)
109+
expect(parsed[0].content).toBe('hello json')
110+
})
111+
112+
test('.json export survives cyclic tool output instead of throwing', async () => {
113+
const { deps, writes } = makeDeps()
114+
const cyclic: Record<string, unknown> = { note: 'loop' }
115+
cyclic.self = cyclic
116+
const messages = [
117+
msg('ai', {
118+
blocks: [
119+
{
120+
type: 'tool',
121+
toolCallId: 'call-1',
122+
toolName: 'read_files' as never,
123+
input: cyclic,
124+
output: 'ok',
125+
},
126+
],
127+
}),
128+
]
129+
130+
const result = await exportConversation(messages, 'out.json', deps)
131+
expect(result).toMatchObject({ ok: true, format: 'json' })
132+
expect(writes[0].data).toContain('[Circular]')
133+
})
134+
135+
test('markdown export has no clipboard ceiling — nothing is trimmed', async () => {
136+
const { deps, writes } = makeDeps()
137+
// Far past the /copy OSC 52 budget (22 KB); with a maxBytes this would be
138+
// replaced by an omission note.
139+
const huge = 'x'.repeat(200_000)
140+
const messages = [
141+
msg('ai', {
142+
blocks: [
143+
{
144+
type: 'tool',
145+
toolCallId: 'call-1',
146+
toolName: 'read_files' as never,
147+
input: { path: 'big.txt' },
148+
output: huge,
149+
},
150+
],
151+
}),
152+
]
153+
154+
const result = await exportConversation(messages, '', deps)
155+
expect(result).toMatchObject({ ok: true })
156+
expect(writes[0].data).toContain(huge)
157+
expect(writes[0].data).not.toContain('omitted')
158+
})
159+
160+
test('reports write failures instead of throwing', async () => {
161+
const { deps } = makeDeps()
162+
deps.writeFile = mock(async () => {
163+
throw new Error('EACCES: permission denied')
164+
})
165+
const result = await exportConversation(
166+
[msg('user', { content: 'hi' })],
167+
'',
168+
deps,
169+
)
170+
expect(result).toMatchObject({ ok: false })
171+
if (!result.ok) expect(result.error).toContain('EACCES')
172+
})
173+
174+
test('reports a missing project root instead of throwing', async () => {
175+
const { deps } = makeDeps()
176+
deps.getProjectRoot = () => {
177+
throw new Error('Project root not set')
178+
}
179+
const result = await exportConversation(
180+
[msg('user', { content: 'hi' })],
181+
'',
182+
deps,
183+
)
184+
expect(result).toMatchObject({ ok: false })
185+
if (!result.ok) expect(result.error).toContain('Project root not set')
186+
})
187+
})

‎cli/src/commands/command-registry.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { safeOpen } from '../utils/open-url'
22

33
import { handleAdsEnable, handleAdsDisable } from './ads'
44
import { handleCopyConversationCommand } from './copy-conversation'
5+
import { handleExportConversationCommand } from './export-conversation'
56
import { handleHelpCommand } from './help'
67
import { handleImageCommand } from './image'
78
import { handleInitializationFlowLocally } from './init'
@@ -235,11 +236,18 @@ const ALL_COMMANDS: CommandDefinition[] = [
235236
}),
236237
defineCommand({
237238
name: 'copy',
238-
aliases: ['copy-chat', 'export'],
239+
aliases: ['copy-chat'],
239240
handler: async (params) => {
240241
await handleCopyConversationCommand(params)
241242
},
242243
}),
244+
defineCommandWithArgs({
245+
name: 'export',
246+
aliases: ['export-chat'],
247+
handler: async (params, args) => {
248+
await handleExportConversationCommand(params, args)
249+
},
250+
}),
243251
defineCommandWithArgs({
244252
name: 'feedback',
245253
aliases: ['bug', 'report'],

0 commit comments

Comments
 (0)