Skip to content

Commit d7d0f25

Browse files
Merge branch 'main' into cursor/cve/qs
2 parents 89329e3 + e017721 commit d7d0f25

10 files changed

Lines changed: 301 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- [EE] Added prompt caching for Ask Sourcebot. For Anthropic models, the static prompt prefix (tool definitions, system prompt, and conversation history) is marked with a cache breakpoint so it is billed at the provider's discounted cache-read rate on subsequent agent steps and follow-up turns. Toggle with `SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED` (default `true`). [#1278](https://github.com/sourcebot-dev/sourcebot/pull/1278)
12+
- [EE] Added a cached-token breakdown to the Ask Sourcebot message details, showing what share of the input tokens were served from the model provider's prompt cache. [#1278](https://github.com/sourcebot-dev/sourcebot/pull/1278)
13+
1014
### Fixed
1115
- Upgraded `protobufjs` to `^7.6.2`. [#1281](https://github.com/sourcebot-dev/sourcebot/pull/1281)
16+
- Fixed GitLab MR inline review comments returning 400 Bad Request on context (unchanged) lines and renamed files. [#1149](https://github.com/sourcebot-dev/sourcebot/pull/1149)
1217

1318
## [5.0.1] - 2026-06-04
1419

docs/docs/upgrade/v4-to-v5-guide.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: V4 to V5 Guide
33
sidebarTitle: V4 to V5 guide
44
---
55

6-
This guide will walk you through upgrading your Sourcebot deployment from v4 to v5.
6+
This guide will walk you through upgrading your Sourcebot deployment from v4 to [v5](https://www.sourcebot.dev/changelog/v5).
77

88
## Breaking Changes
99

packages/shared/src/env.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ const options = {
283283
*/
284284
SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.optional(),
285285
SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
286+
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
286287
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),
287288

288289
DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),

packages/web/src/ee/features/chat/agent.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ export const createMessageStream = async ({
197197
totalTokens: (priorMetadata?.totalTokens ?? 0) + (totalUsage.totalTokens ?? 0),
198198
totalInputTokens: (priorMetadata?.totalInputTokens ?? 0) + (totalUsage.inputTokens ?? 0),
199199
totalOutputTokens: (priorMetadata?.totalOutputTokens ?? 0) + (totalUsage.outputTokens ?? 0),
200+
totalCacheReadTokens: (priorMetadata?.totalCacheReadTokens ?? 0) + (totalUsage.inputTokenDetails?.cacheReadTokens ?? 0),
201+
totalCacheWriteTokens: (priorMetadata?.totalCacheWriteTokens ?? 0) + (totalUsage.inputTokenDetails?.cacheWriteTokens ?? 0),
200202
totalResponseTimeMs: (priorMetadata?.totalResponseTimeMs ?? 0) + (new Date().getTime() - startTime.getTime()),
201203
modelName,
202204
traceId,
@@ -343,11 +345,42 @@ const createAgentStream = async ({
343345
...(hasMcpTools ? { tool_request_activation: toolRequestActivation, ...mcpToolSetsObj.tools } : {}),
344346
};
345347

348+
// Anthropic prompt caching: mark the end of the prompt's static prefix —
349+
// tool definitions, the system prompt (including any resolved file sources),
350+
// and the conversation history — with an ephemeral (5m) cache breakpoint on
351+
// the last input message. Anthropic caches everything up to and including
352+
// this point, so the large prefix is written once (~1.25x) and read back at
353+
// ~0.1x on every subsequent agent step and follow-up turn instead of being
354+
// reprocessed in full. The `anthropic` provider-options namespace is ignored
355+
// by non-Anthropic providers, so this is safe to apply unconditionally.
356+
//
357+
// Caveat: when MCP tools are lazily activated mid-run via prepareStep, the
358+
// tools section (which precedes everything else in the prefix) grows and
359+
// invalidates the cache for that step; the cache re-warms on subsequent
360+
// steps once the active tool set is stable.
361+
const isPromptCachingEnabled = env.SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED === 'true';
362+
const messagesWithCachedPrefix: ModelMessage[] = inputMessages.map((message, index) => {
363+
if (!isPromptCachingEnabled || index !== inputMessages.length - 1) {
364+
return message;
365+
}
366+
367+
return {
368+
...message,
369+
providerOptions: {
370+
...message.providerOptions,
371+
anthropic: {
372+
...message.providerOptions?.anthropic,
373+
cacheControl: { type: 'ephemeral' },
374+
},
375+
},
376+
};
377+
});
378+
346379
try {
347380
const stream = streamText({
348381
model,
349382
providerOptions,
350-
messages: inputMessages,
383+
messages: messagesWithCachedPrefix,
351384
system: systemPrompt,
352385
tools: allTools,
353386
activeTools: [

packages/web/src/ee/features/chat/components/chatThread/detailsCard.tsx

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ const DetailsCardComponent = ({
5959
(part.type === 'dynamic-tool' && part.toolName.startsWith('mcp_'))
6060
).length, [thinkingSteps]);
6161

62+
const cacheReadTokens = metadata?.totalCacheReadTokens ?? 0;
63+
const inputTokens = metadata?.totalInputTokens ?? 0;
64+
const cachedInputPercent = inputTokens > 0
65+
? Math.round((cacheReadTokens / inputTokens) * 100)
66+
: 0;
67+
6268
const handleExpandedChanged = useCallback((next: boolean) => {
6369
captureEvent('wa_chat_details_card_toggled', { chatId, isExpanded: next });
6470
onExpandedChanged(next);
@@ -127,30 +133,44 @@ const DetailsCardComponent = ({
127133
</div>
128134
)}
129135
{metadata?.totalTokens && (
130-
<Tooltip>
131-
<TooltipTrigger asChild>
132-
<div className="flex items-center text-xs cursor-help">
133-
<Zap className="w-3 h-3 mr-1 flex-shrink-0" />
134-
{getShortenedNumberDisplayString(metadata.totalTokens, 0)} tokens
135-
</div>
136-
</TooltipTrigger>
137-
<TooltipContent side="bottom">
138-
<div className="space-y-1 text-xs">
139-
<div className="flex justify-between gap-4">
140-
<span className="text-muted-foreground">Input</span>
141-
<span>{metadata.totalInputTokens?.toLocaleString() ?? '—'}</span>
142-
</div>
143-
<div className="flex justify-between gap-4">
144-
<span className="text-muted-foreground">Output</span>
145-
<span>{metadata.totalOutputTokens?.toLocaleString() ?? '—'}</span>
136+
<div className="flex items-center gap-1.5 text-xs">
137+
<Tooltip>
138+
<TooltipTrigger asChild>
139+
<div className="flex items-center cursor-help">
140+
<Zap className="w-3 h-3 mr-1 flex-shrink-0" />
141+
{getShortenedNumberDisplayString(metadata.totalTokens, 0)} tokens
146142
</div>
147-
<div className="flex justify-between gap-4 border-t border-border pt-1">
148-
<span className="text-muted-foreground">Total</span>
149-
<span>{metadata.totalTokens.toLocaleString()}</span>
143+
</TooltipTrigger>
144+
<TooltipContent side="bottom">
145+
<div className="space-y-1 text-xs">
146+
<div className="flex justify-between gap-4">
147+
<span className="text-muted-foreground">Input</span>
148+
<span>{metadata.totalInputTokens?.toLocaleString() ?? '—'}</span>
149+
</div>
150+
<div className="flex justify-between gap-4">
151+
<span className="text-muted-foreground">Output</span>
152+
<span>{metadata.totalOutputTokens?.toLocaleString() ?? '—'}</span>
153+
</div>
154+
<div className="flex justify-between gap-4 border-t border-border pt-1">
155+
<span className="text-muted-foreground">Total</span>
156+
<span>{metadata.totalTokens.toLocaleString()}</span>
157+
</div>
150158
</div>
151-
</div>
152-
</TooltipContent>
153-
</Tooltip>
159+
</TooltipContent>
160+
</Tooltip>
161+
{cachedInputPercent > 0 && (
162+
<Tooltip>
163+
<TooltipTrigger asChild>
164+
<span className="text-muted-foreground cursor-help">({cachedInputPercent}% cached)</span>
165+
</TooltipTrigger>
166+
<TooltipContent side="bottom">
167+
<div className="max-w-xs text-xs">
168+
{cacheReadTokens.toLocaleString()} of {inputTokens.toLocaleString()} input tokens were read from the model provider prompt cache. Cached tokens are typically billed at a fraction of the cost of regular input tokens, so the real cost is lower than the token count suggests.
169+
</div>
170+
</TooltipContent>
171+
</Tooltip>
172+
)}
173+
</div>
154174
)}
155175
{metadata?.totalResponseTimeMs && (
156176
<div className="flex items-center text-xs">

packages/web/src/features/agents/review-agent/nodes/generatePrReview.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const generatePrReviews = async (reviewAgentLogFileName: string | undefin
4242
if (reviews.length > 0) {
4343
file_diff_reviews.push({
4444
filename: file_diff.to,
45+
oldFilename: file_diff.from,
4546
reviews: reviews,
4647
});
4748
}

packages/web/src/features/agents/review-agent/nodes/gitlabPushMrReviews.test.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { expect, test, vi, describe } from 'vitest';
22
import { Gitlab } from '@gitbeaker/rest';
33
import { gitlabPushMrReviews } from './gitlabPushMrReviews';
4-
import { sourcebot_pr_payload, sourcebot_file_diff_review } from '../types';
4+
import { sourcebot_pr_payload, sourcebot_file_diff_review, sourcebot_diff_refs } from '../types';
55

66
type GitlabClient = InstanceType<typeof Gitlab>;
77

@@ -67,6 +67,7 @@ describe('gitlabPushMrReviews', () => {
6767
baseSha: 'base_sha_value',
6868
headSha: 'head_sha_value',
6969
startSha: 'start_sha_value',
70+
oldPath: 'src/foo.ts',
7071
newPath: 'src/foo.ts',
7172
newLine: '5',
7273
}),
@@ -187,4 +188,142 @@ describe('gitlabPushMrReviews', () => {
187188
gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, SINGLE_REVIEW),
188189
).resolves.not.toThrow();
189190
});
191+
192+
test('uses oldFilename as oldPath when file was renamed', async () => {
193+
const renamedReview: sourcebot_file_diff_review[] = [
194+
{
195+
filename: 'src/new-name.ts',
196+
oldFilename: 'src/old-name.ts',
197+
reviews: [{ line_start: 1, line_end: 1, review: 'Comment on renamed file' }],
198+
},
199+
];
200+
const client = makeMockClient();
201+
202+
await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, renamedReview);
203+
204+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
205+
101,
206+
42,
207+
'Comment on renamed file',
208+
expect.objectContaining({
209+
position: expect.objectContaining({
210+
oldPath: 'src/old-name.ts',
211+
newPath: 'src/new-name.ts',
212+
}),
213+
}),
214+
);
215+
});
216+
217+
test('passes oldLine for context lines', async () => {
218+
// old line 47 = new line 48 (a line was added at new line 47 before it)
219+
const payloadWithDiffs: sourcebot_pr_payload = {
220+
...MOCK_PAYLOAD,
221+
file_diffs: [{
222+
from: 'src/foo.ts',
223+
to: 'src/foo.ts',
224+
diffs: [{
225+
oldSnippet: '@@ -47,1 +48,1 @@\n47: context line\n',
226+
newSnippet: '@@ -47,1 +48,1 @@\n48: context line\n',
227+
}],
228+
}],
229+
};
230+
const contextReview: sourcebot_file_diff_review[] = [
231+
{
232+
filename: 'src/foo.ts',
233+
reviews: [{ line_start: 48, line_end: 48, review: 'Context line comment' }],
234+
},
235+
];
236+
const client = makeMockClient();
237+
238+
await gitlabPushMrReviews(client, 101, payloadWithDiffs, contextReview);
239+
240+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
241+
101,
242+
42,
243+
'Context line comment',
244+
expect.objectContaining({
245+
position: expect.objectContaining({
246+
newLine: '48',
247+
oldLine: '47',
248+
}),
249+
}),
250+
);
251+
});
252+
253+
test('does not pass oldLine for added lines', async () => {
254+
const payloadWithDiffs: sourcebot_pr_payload = {
255+
...MOCK_PAYLOAD,
256+
file_diffs: [{
257+
from: 'src/foo.ts',
258+
to: 'src/foo.ts',
259+
diffs: [{
260+
oldSnippet: '@@ -1,1 +1,2 @@\n1: existing line\n',
261+
newSnippet: '@@ -1,1 +1,2 @@\n1: existing line\n2:+added line\n',
262+
}],
263+
}],
264+
};
265+
const addedLineReview: sourcebot_file_diff_review[] = [
266+
{
267+
filename: 'src/foo.ts',
268+
reviews: [{ line_start: 2, line_end: 2, review: 'Comment on added line' }],
269+
},
270+
];
271+
const client = makeMockClient();
272+
273+
await gitlabPushMrReviews(client, 101, payloadWithDiffs, addedLineReview);
274+
275+
const call = client.MergeRequestDiscussions.create.mock.calls[0][3];
276+
expect(call.position).not.toHaveProperty('oldLine');
277+
expect(call.position.newLine).toBe('2');
278+
});
279+
280+
test('uses new path for both oldPath and newPath when old path is /dev/null (added file)', async () => {
281+
const addedFileReview: sourcebot_file_diff_review[] = [
282+
{
283+
filename: 'src/new-file.ts',
284+
oldFilename: '/dev/null',
285+
reviews: [{ line_start: 1, line_end: 1, review: 'Comment on new file' }],
286+
},
287+
];
288+
const client = makeMockClient();
289+
290+
await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, addedFileReview);
291+
292+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
293+
101,
294+
42,
295+
'Comment on new file',
296+
expect.objectContaining({
297+
position: expect.objectContaining({
298+
oldPath: 'src/new-file.ts',
299+
newPath: 'src/new-file.ts',
300+
}),
301+
}),
302+
);
303+
});
304+
305+
test('uses old path for both oldPath and newPath when new path is /dev/null (deleted file)', async () => {
306+
const deletedFileReview: sourcebot_file_diff_review[] = [
307+
{
308+
filename: '/dev/null',
309+
oldFilename: 'src/deleted-file.ts',
310+
reviews: [{ line_start: 1, line_end: 1, review: 'Comment on deleted file' }],
311+
},
312+
];
313+
const client = makeMockClient();
314+
315+
await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, deletedFileReview);
316+
317+
expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
318+
101,
319+
42,
320+
'Comment on deleted file',
321+
expect.objectContaining({
322+
position: expect.objectContaining({
323+
oldPath: 'src/deleted-file.ts',
324+
newPath: 'src/deleted-file.ts',
325+
}),
326+
}),
327+
);
328+
});
190329
});

0 commit comments

Comments
 (0)