Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)

### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -311,6 +311,14 @@ const options = {

SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
/** TTL for the static block. The moving tail marker always uses the 5m default. */
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
/**
* Observability: when enabled, logs a warning on unexpected prompt-cache
* breaks (static-prefix signature changes, or zero cache reads on a
* continuation step). Does not affect request behavior.
*/
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: booleanSchema.default('false'),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),

DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/app/api/(server)/ee/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { sew } from "@/middleware/sew";
import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from "@/ee/features/chat/askMcpAnalytics.server";
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
import { getLanguageModelKey } from "@/features/chat/utils";
import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
Expand DownExpand Up@@ -88,6 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);

// No-op for non-Anthropic providers / when caching is disabled, so
// it never perturbs other providers' requests.
const promptCacheStrategy = getPromptCacheStrategy(
languageModelConfig.provider,
env.SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED === 'true',
);

const expandedRepos = (await Promise.all(selectedSearchScopes.map(async (scope) => {
if (scope.type === 'repo') return [scope.value];
if (scope.type === 'reposet') {
Expand DownExpand Up@@ -131,6 +139,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
disabledMcpServerIds,
model,
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
promptCacheStrategy,
modelProviderOptions: providerOptions,
modelTemperature: temperature,
userId: user?.id,
Expand Down
192 changes: 187 additions & 5 deletions packages/web/src/ee/features/chat/agent.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ModelMessage } from 'ai';
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { SBChatMessage, SBChatMessagePart } from '@/features/chat/types';
import type { PromptCacheStrategy } from './promptCaching';

const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
Expand All@@ -25,6 +27,8 @@ vi.mock('@sourcebot/shared', () => ({
SOURCEBOT_CHAT_MAX_STEP_COUNT: 8,
SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0,
SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false',
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: '5m',
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: 'false',
},
getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@db.example.com:5432/sourcebot',
}));
Expand DownExpand Up@@ -94,6 +98,11 @@ vi.mock('ai', async (importOriginal) => {
});

const { createMessageStream } = await import('./agent');
const { getPromptCacheStrategy } = await import('./promptCaching');

// Strategies reused across the prompt-caching tests below.
const anthropicStrategy = getPromptCacheStrategy('anthropic', true);
const noopStrategy = getPromptCacheStrategy('openai', true);

const listReposInput = {
sort: 'name',
Expand DownExpand Up@@ -150,7 +159,29 @@ const createFakeStreamResult = () => ({
}),
});

const runCreateMessageStream = async (messages: SBChatMessage[]) => {
type FakePrepareStep = (opts: {
steps: Array<{ toolResults: Array<{ toolName: string; output: unknown }> }>;
stepNumber: number;
model: unknown;
messages: ModelMessage[];
}) =>
| { messages?: ModelMessage[]; activeTools?: string[] }
| Promise<{ messages?: ModelMessage[]; activeTools?: string[] }>;

interface StreamTextArgs {
messages: ModelMessage[];
system: Array<{ role: 'system'; content: string; providerOptions?: ProviderOptions }>;
tools: Record<string, { providerOptions?: ProviderOptions }>;
prepareStep?: FakePrepareStep;
}

const runCreateMessageStream = async (
messages: SBChatMessage[],
opts: {
promptCacheStrategy?: PromptCacheStrategy;
selectedRepos?: string[];
} = {},
): Promise<StreamTextArgs> => {
const convertedLastTurn: ModelMessage = {
role: 'assistant',
content: 'converted-last-turn',
Expand All@@ -161,10 +192,13 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
const props = {
chatId: 'chat-id',
messages,
selectedRepos: [],
selectedRepos: opts.selectedRepos ?? [],
prisma: {},
model: {},
modelName: 'test-model',
// Default to a no-op strategy so the approval-continuation tests below
// (which assert plain, unmarked messages) are unaffected by caching.
promptCacheStrategy: opts.promptCacheStrategy ?? noopStrategy,
onFinish: vi.fn(),
onError: () => 'error',
} as unknown as Parameters<typeof createMessageStream>[0];
Expand All@@ -188,7 +222,7 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
throw new Error('Expected streamText to be called with messages.');
}

return streamTextArgs.messages as ModelMessage[];
return streamTextArgs as StreamTextArgs;
};

beforeEach(() => {
Expand DownExpand Up@@ -216,7 +250,7 @@ describe('createMessageStream approval continuation', () => {
approvalPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand DownExpand Up@@ -249,7 +283,7 @@ describe('createMessageStream approval continuation', () => {
dynamicApprovalRespondedPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand All@@ -265,3 +299,151 @@ describe('createMessageStream approval continuation', () => {
});
});
});

const EPHEMERAL = { type: 'ephemeral' };

describe('createMessageStream prompt caching', () => {
test('marks the static system block for the Anthropic family', async () => {
const { system, messages } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// No repos / files / MCP tools → only the static system block.
expect(system).toHaveLength(1);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// The tail marker is applied per-step in prepareStep, not on the messages
// handed to streamText — those stay unmarked.
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
});

test('moves the tail marker onto the last message of each step via prepareStep', async () => {
const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});
expect(prepareStep).toBeTypeOf('function');

// Step 0: a single input message → marker lands on it.
const step0 = await prepareStep!({
steps: [],
stepNumber: 0,
model: {},
messages: [{ role: 'user', content: 'q' }],
});
expect(step0.messages?.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// Continuation step: the marker rides the NEW last message, and only it —
// earlier messages (including the prior tail) carry no marker.
const stepN = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'searching' },
{ role: 'assistant', content: 'tool output' },
],
});
const out = stepN.messages!;
expect(out[0].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out[1].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
});

test('prepareStep adds no tail marker for non-Anthropic providers', async () => {
// Force MCP so prepareStep exists even without a tail marker.
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: noopStrategy,
});
expect(prepareStep).toBeTypeOf('function');

const result = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'a' },
],
});

// activeTools still managed (MCP), but no message override / marker.
expect(result.messages).toBeUndefined();
expect(result.activeTools).toContain('tool_request_activation');
});

test('leaves the dynamic system block uncached', async () => {
const { system } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/repo'],
});

// Static checkpoint + dynamic (per-conversation) block.
expect(system).toHaveLength(2);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
expect(system[1].providerOptions).toBeUndefined();
expect(system[1].content).toContain('<selected_repositories>');
});

test('does not mark the tools block, so mid-run activeTools growth never busts it', async () => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// The static checkpoint sits on the system block (after the full tools
// section in render order), so no tool definition carries a breakpoint.
for (const tool of Object.values(tools)) {
expect(tool.providerOptions?.anthropic?.cacheControl).toBeUndefined();
}
});

test.each([
['non-Anthropic provider', () => getPromptCacheStrategy('openai', true)],
['caching disabled', () => getPromptCacheStrategy('anthropic', false)],
])('emits no cache markers for %s (multi-provider regression guard)', async (_label, makeStrategy) => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { system, messages, tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: makeStrategy(),
selectedRepos: ['github.com/acme/repo'],
});

for (const block of system) {
expect(block.providerOptions).toBeUndefined();
}
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
for (const tool of Object.values(tools)) {
expect(tool.providerOptions).toBeUndefined();
}
});

test('builds a byte-identical static prompt regardless of repos', async () => {
const first = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/one'],
});
const second = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/two', 'github.com/acme/three'],
});

expect(first.system[0].content).toBe(second.system[0].content);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)

### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -311,6 +311,14 @@ const options = {

SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
/** TTL for the static block. The moving tail marker always uses the 5m default. */
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
/**
* Observability: when enabled, logs a warning on unexpected prompt-cache
* breaks (static-prefix signature changes, or zero cache reads on a
* continuation step). Does not affect request behavior.
*/
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: booleanSchema.default('false'),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),

DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/app/api/(server)/ee/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { sew } from "@/middleware/sew";
import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from "@/ee/features/chat/askMcpAnalytics.server";
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
import { getLanguageModelKey } from "@/features/chat/utils";
import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
Expand DownExpand Up@@ -88,6 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);

// No-op for non-Anthropic providers / when caching is disabled, so
// it never perturbs other providers' requests.
const promptCacheStrategy = getPromptCacheStrategy(
languageModelConfig.provider,
env.SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED === 'true',
);

const expandedRepos = (await Promise.all(selectedSearchScopes.map(async (scope) => {
if (scope.type === 'repo') return [scope.value];
if (scope.type === 'reposet') {
Expand DownExpand Up@@ -131,6 +139,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
disabledMcpServerIds,
model,
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
promptCacheStrategy,
modelProviderOptions: providerOptions,
modelTemperature: temperature,
userId: user?.id,
Expand Down
192 changes: 187 additions & 5 deletions packages/web/src/ee/features/chat/agent.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ModelMessage } from 'ai';
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { SBChatMessage, SBChatMessagePart } from '@/features/chat/types';
import type { PromptCacheStrategy } from './promptCaching';

const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
Expand All@@ -25,6 +27,8 @@ vi.mock('@sourcebot/shared', () => ({
SOURCEBOT_CHAT_MAX_STEP_COUNT: 8,
SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0,
SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false',
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: '5m',
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: 'false',
},
getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@db.example.com:5432/sourcebot',
}));
Expand DownExpand Up@@ -94,6 +98,11 @@ vi.mock('ai', async (importOriginal) => {
});

const { createMessageStream } = await import('./agent');
const { getPromptCacheStrategy } = await import('./promptCaching');

// Strategies reused across the prompt-caching tests below.
const anthropicStrategy = getPromptCacheStrategy('anthropic', true);
const noopStrategy = getPromptCacheStrategy('openai', true);

const listReposInput = {
sort: 'name',
Expand DownExpand Up@@ -150,7 +159,29 @@ const createFakeStreamResult = () => ({
}),
});

const runCreateMessageStream = async (messages: SBChatMessage[]) => {
type FakePrepareStep = (opts: {
steps: Array<{ toolResults: Array<{ toolName: string; output: unknown }> }>;
stepNumber: number;
model: unknown;
messages: ModelMessage[];
}) =>
| { messages?: ModelMessage[]; activeTools?: string[] }
| Promise<{ messages?: ModelMessage[]; activeTools?: string[] }>;

interface StreamTextArgs {
messages: ModelMessage[];
system: Array<{ role: 'system'; content: string; providerOptions?: ProviderOptions }>;
tools: Record<string, { providerOptions?: ProviderOptions }>;
prepareStep?: FakePrepareStep;
}

const runCreateMessageStream = async (
messages: SBChatMessage[],
opts: {
promptCacheStrategy?: PromptCacheStrategy;
selectedRepos?: string[];
} = {},
): Promise<StreamTextArgs> => {
const convertedLastTurn: ModelMessage = {
role: 'assistant',
content: 'converted-last-turn',
Expand All@@ -161,10 +192,13 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
const props = {
chatId: 'chat-id',
messages,
selectedRepos: [],
selectedRepos: opts.selectedRepos ?? [],
prisma: {},
model: {},
modelName: 'test-model',
// Default to a no-op strategy so the approval-continuation tests below
// (which assert plain, unmarked messages) are unaffected by caching.
promptCacheStrategy: opts.promptCacheStrategy ?? noopStrategy,
onFinish: vi.fn(),
onError: () => 'error',
} as unknown as Parameters<typeof createMessageStream>[0];
Expand All@@ -188,7 +222,7 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
throw new Error('Expected streamText to be called with messages.');
}

return streamTextArgs.messages as ModelMessage[];
return streamTextArgs as StreamTextArgs;
};

beforeEach(() => {
Expand DownExpand Up@@ -216,7 +250,7 @@ describe('createMessageStream approval continuation', () => {
approvalPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand DownExpand Up@@ -249,7 +283,7 @@ describe('createMessageStream approval continuation', () => {
dynamicApprovalRespondedPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand All@@ -265,3 +299,151 @@ describe('createMessageStream approval continuation', () => {
});
});
});

const EPHEMERAL = { type: 'ephemeral' };

describe('createMessageStream prompt caching', () => {
test('marks the static system block for the Anthropic family', async () => {
const { system, messages } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// No repos / files / MCP tools → only the static system block.
expect(system).toHaveLength(1);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// The tail marker is applied per-step in prepareStep, not on the messages
// handed to streamText — those stay unmarked.
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
});

test('moves the tail marker onto the last message of each step via prepareStep', async () => {
const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});
expect(prepareStep).toBeTypeOf('function');

// Step 0: a single input message → marker lands on it.
const step0 = await prepareStep!({
steps: [],
stepNumber: 0,
model: {},
messages: [{ role: 'user', content: 'q' }],
});
expect(step0.messages?.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// Continuation step: the marker rides the NEW last message, and only it —
// earlier messages (including the prior tail) carry no marker.
const stepN = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'searching' },
{ role: 'assistant', content: 'tool output' },
],
});
const out = stepN.messages!;
expect(out[0].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out[1].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
});

test('prepareStep adds no tail marker for non-Anthropic providers', async () => {
// Force MCP so prepareStep exists even without a tail marker.
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: noopStrategy,
});
expect(prepareStep).toBeTypeOf('function');

const result = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'a' },
],
});

// activeTools still managed (MCP), but no message override / marker.
expect(result.messages).toBeUndefined();
expect(result.activeTools).toContain('tool_request_activation');
});

test('leaves the dynamic system block uncached', async () => {
const { system } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/repo'],
});

// Static checkpoint + dynamic (per-conversation) block.
expect(system).toHaveLength(2);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
expect(system[1].providerOptions).toBeUndefined();
expect(system[1].content).toContain('<selected_repositories>');
});

test('does not mark the tools block, so mid-run activeTools growth never busts it', async () => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// The static checkpoint sits on the system block (after the full tools
// section in render order), so no tool definition carries a breakpoint.
for (const tool of Object.values(tools)) {
expect(tool.providerOptions?.anthropic?.cacheControl).toBeUndefined();
}
});

test.each([
['non-Anthropic provider', () => getPromptCacheStrategy('openai', true)],
['caching disabled', () => getPromptCacheStrategy('anthropic', false)],
])('emits no cache markers for %s (multi-provider regression guard)', async (_label, makeStrategy) => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { system, messages, tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: makeStrategy(),
selectedRepos: ['github.com/acme/repo'],
});

for (const block of system) {
expect(block.providerOptions).toBeUndefined();
}
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
for (const tool of Object.values(tools)) {
expect(tool.providerOptions).toBeUndefined();
}
});

test('builds a byte-identical static prompt regardless of repos', async () => {
const first = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/one'],
});
const second = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/two', 'github.com/acme/three'],
});

expect(first.system[0].content).toBe(second.system[0].content);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)

### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -311,6 +311,14 @@ const options = {

SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
/** TTL for the static block. The moving tail marker always uses the 5m default. */
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
/**
* Observability: when enabled, logs a warning on unexpected prompt-cache
* breaks (static-prefix signature changes, or zero cache reads on a
* continuation step). Does not affect request behavior.
*/
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: booleanSchema.default('false'),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),

DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/app/api/(server)/ee/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { sew } from "@/middleware/sew";
import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from "@/ee/features/chat/askMcpAnalytics.server";
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
import { getLanguageModelKey } from "@/features/chat/utils";
import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
Expand DownExpand Up@@ -88,6 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);

// No-op for non-Anthropic providers / when caching is disabled, so
// it never perturbs other providers' requests.
const promptCacheStrategy = getPromptCacheStrategy(
languageModelConfig.provider,
env.SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED === 'true',
);

const expandedRepos = (await Promise.all(selectedSearchScopes.map(async (scope) => {
if (scope.type === 'repo') return [scope.value];
if (scope.type === 'reposet') {
Expand DownExpand Up@@ -131,6 +139,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
disabledMcpServerIds,
model,
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
promptCacheStrategy,
modelProviderOptions: providerOptions,
modelTemperature: temperature,
userId: user?.id,
Expand Down
192 changes: 187 additions & 5 deletions packages/web/src/ee/features/chat/agent.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ModelMessage } from 'ai';
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { SBChatMessage, SBChatMessagePart } from '@/features/chat/types';
import type { PromptCacheStrategy } from './promptCaching';

const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
Expand All@@ -25,6 +27,8 @@ vi.mock('@sourcebot/shared', () => ({
SOURCEBOT_CHAT_MAX_STEP_COUNT: 8,
SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0,
SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false',
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: '5m',
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: 'false',
},
getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@db.example.com:5432/sourcebot',
}));
Expand DownExpand Up@@ -94,6 +98,11 @@ vi.mock('ai', async (importOriginal) => {
});

const { createMessageStream } = await import('./agent');
const { getPromptCacheStrategy } = await import('./promptCaching');

// Strategies reused across the prompt-caching tests below.
const anthropicStrategy = getPromptCacheStrategy('anthropic', true);
const noopStrategy = getPromptCacheStrategy('openai', true);

const listReposInput = {
sort: 'name',
Expand DownExpand Up@@ -150,7 +159,29 @@ const createFakeStreamResult = () => ({
}),
});

const runCreateMessageStream = async (messages: SBChatMessage[]) => {
type FakePrepareStep = (opts: {
steps: Array<{ toolResults: Array<{ toolName: string; output: unknown }> }>;
stepNumber: number;
model: unknown;
messages: ModelMessage[];
}) =>
| { messages?: ModelMessage[]; activeTools?: string[] }
| Promise<{ messages?: ModelMessage[]; activeTools?: string[] }>;

interface StreamTextArgs {
messages: ModelMessage[];
system: Array<{ role: 'system'; content: string; providerOptions?: ProviderOptions }>;
tools: Record<string, { providerOptions?: ProviderOptions }>;
prepareStep?: FakePrepareStep;
}

const runCreateMessageStream = async (
messages: SBChatMessage[],
opts: {
promptCacheStrategy?: PromptCacheStrategy;
selectedRepos?: string[];
} = {},
): Promise<StreamTextArgs> => {
const convertedLastTurn: ModelMessage = {
role: 'assistant',
content: 'converted-last-turn',
Expand All@@ -161,10 +192,13 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
const props = {
chatId: 'chat-id',
messages,
selectedRepos: [],
selectedRepos: opts.selectedRepos ?? [],
prisma: {},
model: {},
modelName: 'test-model',
// Default to a no-op strategy so the approval-continuation tests below
// (which assert plain, unmarked messages) are unaffected by caching.
promptCacheStrategy: opts.promptCacheStrategy ?? noopStrategy,
onFinish: vi.fn(),
onError: () => 'error',
} as unknown as Parameters<typeof createMessageStream>[0];
Expand All@@ -188,7 +222,7 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
throw new Error('Expected streamText to be called with messages.');
}

return streamTextArgs.messages as ModelMessage[];
return streamTextArgs as StreamTextArgs;
};

beforeEach(() => {
Expand DownExpand Up@@ -216,7 +250,7 @@ describe('createMessageStream approval continuation', () => {
approvalPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand DownExpand Up@@ -249,7 +283,7 @@ describe('createMessageStream approval continuation', () => {
dynamicApprovalRespondedPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand All@@ -265,3 +299,151 @@ describe('createMessageStream approval continuation', () => {
});
});
});

const EPHEMERAL = { type: 'ephemeral' };

describe('createMessageStream prompt caching', () => {
test('marks the static system block for the Anthropic family', async () => {
const { system, messages } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// No repos / files / MCP tools → only the static system block.
expect(system).toHaveLength(1);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// The tail marker is applied per-step in prepareStep, not on the messages
// handed to streamText — those stay unmarked.
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
});

test('moves the tail marker onto the last message of each step via prepareStep', async () => {
const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});
expect(prepareStep).toBeTypeOf('function');

// Step 0: a single input message → marker lands on it.
const step0 = await prepareStep!({
steps: [],
stepNumber: 0,
model: {},
messages: [{ role: 'user', content: 'q' }],
});
expect(step0.messages?.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// Continuation step: the marker rides the NEW last message, and only it —
// earlier messages (including the prior tail) carry no marker.
const stepN = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'searching' },
{ role: 'assistant', content: 'tool output' },
],
});
const out = stepN.messages!;
expect(out[0].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out[1].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
});

test('prepareStep adds no tail marker for non-Anthropic providers', async () => {
// Force MCP so prepareStep exists even without a tail marker.
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: noopStrategy,
});
expect(prepareStep).toBeTypeOf('function');

const result = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'a' },
],
});

// activeTools still managed (MCP), but no message override / marker.
expect(result.messages).toBeUndefined();
expect(result.activeTools).toContain('tool_request_activation');
});

test('leaves the dynamic system block uncached', async () => {
const { system } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/repo'],
});

// Static checkpoint + dynamic (per-conversation) block.
expect(system).toHaveLength(2);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
expect(system[1].providerOptions).toBeUndefined();
expect(system[1].content).toContain('<selected_repositories>');
});

test('does not mark the tools block, so mid-run activeTools growth never busts it', async () => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// The static checkpoint sits on the system block (after the full tools
// section in render order), so no tool definition carries a breakpoint.
for (const tool of Object.values(tools)) {
expect(tool.providerOptions?.anthropic?.cacheControl).toBeUndefined();
}
});

test.each([
['non-Anthropic provider', () => getPromptCacheStrategy('openai', true)],
['caching disabled', () => getPromptCacheStrategy('anthropic', false)],
])('emits no cache markers for %s (multi-provider regression guard)', async (_label, makeStrategy) => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { system, messages, tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: makeStrategy(),
selectedRepos: ['github.com/acme/repo'],
});

for (const block of system) {
expect(block.providerOptions).toBeUndefined();
}
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
for (const tool of Object.values(tools)) {
expect(tool.providerOptions).toBeUndefined();
}
});

test('builds a byte-identical static prompt regardless of repos', async () => {
const first = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/one'],
});
const second = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/two', 'github.com/acme/three'],
});

expect(first.system[0].content).toBe(second.system[0].content);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)

### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -311,6 +311,14 @@ const options = {

SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
/** TTL for the static block. The moving tail marker always uses the 5m default. */
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
/**
* Observability: when enabled, logs a warning on unexpected prompt-cache
* breaks (static-prefix signature changes, or zero cache reads on a
* continuation step). Does not affect request behavior.
*/
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: booleanSchema.default('false'),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),

DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/app/api/(server)/ee/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { sew } from "@/middleware/sew";
import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from "@/ee/features/chat/askMcpAnalytics.server";
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
import { getLanguageModelKey } from "@/features/chat/utils";
import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
Expand DownExpand Up@@ -88,6 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);

// No-op for non-Anthropic providers / when caching is disabled, so
// it never perturbs other providers' requests.
const promptCacheStrategy = getPromptCacheStrategy(
languageModelConfig.provider,
env.SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED === 'true',
);

const expandedRepos = (await Promise.all(selectedSearchScopes.map(async (scope) => {
if (scope.type === 'repo') return [scope.value];
if (scope.type === 'reposet') {
Expand DownExpand Up@@ -131,6 +139,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
disabledMcpServerIds,
model,
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
promptCacheStrategy,
modelProviderOptions: providerOptions,
modelTemperature: temperature,
userId: user?.id,
Expand Down
192 changes: 187 additions & 5 deletions packages/web/src/ee/features/chat/agent.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ModelMessage } from 'ai';
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { SBChatMessage, SBChatMessagePart } from '@/features/chat/types';
import type { PromptCacheStrategy } from './promptCaching';

const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
Expand All@@ -25,6 +27,8 @@ vi.mock('@sourcebot/shared', () => ({
SOURCEBOT_CHAT_MAX_STEP_COUNT: 8,
SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0,
SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false',
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: '5m',
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: 'false',
},
getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@db.example.com:5432/sourcebot',
}));
Expand DownExpand Up@@ -94,6 +98,11 @@ vi.mock('ai', async (importOriginal) => {
});

const { createMessageStream } = await import('./agent');
const { getPromptCacheStrategy } = await import('./promptCaching');

// Strategies reused across the prompt-caching tests below.
const anthropicStrategy = getPromptCacheStrategy('anthropic', true);
const noopStrategy = getPromptCacheStrategy('openai', true);

const listReposInput = {
sort: 'name',
Expand DownExpand Up@@ -150,7 +159,29 @@ const createFakeStreamResult = () => ({
}),
});

const runCreateMessageStream = async (messages: SBChatMessage[]) => {
type FakePrepareStep = (opts: {
steps: Array<{ toolResults: Array<{ toolName: string; output: unknown }> }>;
stepNumber: number;
model: unknown;
messages: ModelMessage[];
}) =>
| { messages?: ModelMessage[]; activeTools?: string[] }
| Promise<{ messages?: ModelMessage[]; activeTools?: string[] }>;

interface StreamTextArgs {
messages: ModelMessage[];
system: Array<{ role: 'system'; content: string; providerOptions?: ProviderOptions }>;
tools: Record<string, { providerOptions?: ProviderOptions }>;
prepareStep?: FakePrepareStep;
}

const runCreateMessageStream = async (
messages: SBChatMessage[],
opts: {
promptCacheStrategy?: PromptCacheStrategy;
selectedRepos?: string[];
} = {},
): Promise<StreamTextArgs> => {
const convertedLastTurn: ModelMessage = {
role: 'assistant',
content: 'converted-last-turn',
Expand All@@ -161,10 +192,13 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
const props = {
chatId: 'chat-id',
messages,
selectedRepos: [],
selectedRepos: opts.selectedRepos ?? [],
prisma: {},
model: {},
modelName: 'test-model',
// Default to a no-op strategy so the approval-continuation tests below
// (which assert plain, unmarked messages) are unaffected by caching.
promptCacheStrategy: opts.promptCacheStrategy ?? noopStrategy,
onFinish: vi.fn(),
onError: () => 'error',
} as unknown as Parameters<typeof createMessageStream>[0];
Expand All@@ -188,7 +222,7 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
throw new Error('Expected streamText to be called with messages.');
}

return streamTextArgs.messages as ModelMessage[];
return streamTextArgs as StreamTextArgs;
};

beforeEach(() => {
Expand DownExpand Up@@ -216,7 +250,7 @@ describe('createMessageStream approval continuation', () => {
approvalPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand DownExpand Up@@ -249,7 +283,7 @@ describe('createMessageStream approval continuation', () => {
dynamicApprovalRespondedPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand All@@ -265,3 +299,151 @@ describe('createMessageStream approval continuation', () => {
});
});
});

const EPHEMERAL = { type: 'ephemeral' };

describe('createMessageStream prompt caching', () => {
test('marks the static system block for the Anthropic family', async () => {
const { system, messages } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// No repos / files / MCP tools → only the static system block.
expect(system).toHaveLength(1);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// The tail marker is applied per-step in prepareStep, not on the messages
// handed to streamText — those stay unmarked.
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
});

test('moves the tail marker onto the last message of each step via prepareStep', async () => {
const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});
expect(prepareStep).toBeTypeOf('function');

// Step 0: a single input message → marker lands on it.
const step0 = await prepareStep!({
steps: [],
stepNumber: 0,
model: {},
messages: [{ role: 'user', content: 'q' }],
});
expect(step0.messages?.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// Continuation step: the marker rides the NEW last message, and only it —
// earlier messages (including the prior tail) carry no marker.
const stepN = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'searching' },
{ role: 'assistant', content: 'tool output' },
],
});
const out = stepN.messages!;
expect(out[0].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out[1].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
});

test('prepareStep adds no tail marker for non-Anthropic providers', async () => {
// Force MCP so prepareStep exists even without a tail marker.
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: noopStrategy,
});
expect(prepareStep).toBeTypeOf('function');

const result = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'a' },
],
});

// activeTools still managed (MCP), but no message override / marker.
expect(result.messages).toBeUndefined();
expect(result.activeTools).toContain('tool_request_activation');
});

test('leaves the dynamic system block uncached', async () => {
const { system } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/repo'],
});

// Static checkpoint + dynamic (per-conversation) block.
expect(system).toHaveLength(2);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
expect(system[1].providerOptions).toBeUndefined();
expect(system[1].content).toContain('<selected_repositories>');
});

test('does not mark the tools block, so mid-run activeTools growth never busts it', async () => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// The static checkpoint sits on the system block (after the full tools
// section in render order), so no tool definition carries a breakpoint.
for (const tool of Object.values(tools)) {
expect(tool.providerOptions?.anthropic?.cacheControl).toBeUndefined();
}
});

test.each([
['non-Anthropic provider', () => getPromptCacheStrategy('openai', true)],
['caching disabled', () => getPromptCacheStrategy('anthropic', false)],
])('emits no cache markers for %s (multi-provider regression guard)', async (_label, makeStrategy) => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { system, messages, tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: makeStrategy(),
selectedRepos: ['github.com/acme/repo'],
});

for (const block of system) {
expect(block.providerOptions).toBeUndefined();
}
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
for (const tool of Object.values(tools)) {
expect(tool.providerOptions).toBeUndefined();
}
});

test('builds a byte-identical static prompt regardless of repos', async () => {
const first = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/one'],
});
const second = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/two', 'github.com/acme/three'],
});

expect(first.system[0].content).toBe(second.system[0].content);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)

### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -311,6 +311,14 @@ const options = {

SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
/** TTL for the static block. The moving tail marker always uses the 5m default. */
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
/**
* Observability: when enabled, logs a warning on unexpected prompt-cache
* breaks (static-prefix signature changes, or zero cache reads on a
* continuation step). Does not affect request behavior.
*/
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: booleanSchema.default('false'),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),

DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/app/api/(server)/ee/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { sew } from "@/middleware/sew";
import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from "@/ee/features/chat/askMcpAnalytics.server";
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
import { getLanguageModelKey } from "@/features/chat/utils";
import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
Expand DownExpand Up@@ -88,6 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);

// No-op for non-Anthropic providers / when caching is disabled, so
// it never perturbs other providers' requests.
const promptCacheStrategy = getPromptCacheStrategy(
languageModelConfig.provider,
env.SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED === 'true',
);

const expandedRepos = (await Promise.all(selectedSearchScopes.map(async (scope) => {
if (scope.type === 'repo') return [scope.value];
if (scope.type === 'reposet') {
Expand DownExpand Up@@ -131,6 +139,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
disabledMcpServerIds,
model,
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
promptCacheStrategy,
modelProviderOptions: providerOptions,
modelTemperature: temperature,
userId: user?.id,
Expand Down
192 changes: 187 additions & 5 deletions packages/web/src/ee/features/chat/agent.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ModelMessage } from 'ai';
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { SBChatMessage, SBChatMessagePart } from '@/features/chat/types';
import type { PromptCacheStrategy } from './promptCaching';

const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
Expand All@@ -25,6 +27,8 @@ vi.mock('@sourcebot/shared', () => ({
SOURCEBOT_CHAT_MAX_STEP_COUNT: 8,
SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0,
SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false',
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: '5m',
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: 'false',
},
getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@db.example.com:5432/sourcebot',
}));
Expand DownExpand Up@@ -94,6 +98,11 @@ vi.mock('ai', async (importOriginal) => {
});

const { createMessageStream } = await import('./agent');
const { getPromptCacheStrategy } = await import('./promptCaching');

// Strategies reused across the prompt-caching tests below.
const anthropicStrategy = getPromptCacheStrategy('anthropic', true);
const noopStrategy = getPromptCacheStrategy('openai', true);

const listReposInput = {
sort: 'name',
Expand DownExpand Up@@ -150,7 +159,29 @@ const createFakeStreamResult = () => ({
}),
});

const runCreateMessageStream = async (messages: SBChatMessage[]) => {
type FakePrepareStep = (opts: {
steps: Array<{ toolResults: Array<{ toolName: string; output: unknown }> }>;
stepNumber: number;
model: unknown;
messages: ModelMessage[];
}) =>
| { messages?: ModelMessage[]; activeTools?: string[] }
| Promise<{ messages?: ModelMessage[]; activeTools?: string[] }>;

interface StreamTextArgs {
messages: ModelMessage[];
system: Array<{ role: 'system'; content: string; providerOptions?: ProviderOptions }>;
tools: Record<string, { providerOptions?: ProviderOptions }>;
prepareStep?: FakePrepareStep;
}

const runCreateMessageStream = async (
messages: SBChatMessage[],
opts: {
promptCacheStrategy?: PromptCacheStrategy;
selectedRepos?: string[];
} = {},
): Promise<StreamTextArgs> => {
const convertedLastTurn: ModelMessage = {
role: 'assistant',
content: 'converted-last-turn',
Expand All@@ -161,10 +192,13 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
const props = {
chatId: 'chat-id',
messages,
selectedRepos: [],
selectedRepos: opts.selectedRepos ?? [],
prisma: {},
model: {},
modelName: 'test-model',
// Default to a no-op strategy so the approval-continuation tests below
// (which assert plain, unmarked messages) are unaffected by caching.
promptCacheStrategy: opts.promptCacheStrategy ?? noopStrategy,
onFinish: vi.fn(),
onError: () => 'error',
} as unknown as Parameters<typeof createMessageStream>[0];
Expand All@@ -188,7 +222,7 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
throw new Error('Expected streamText to be called with messages.');
}

return streamTextArgs.messages as ModelMessage[];
return streamTextArgs as StreamTextArgs;
};

beforeEach(() => {
Expand DownExpand Up@@ -216,7 +250,7 @@ describe('createMessageStream approval continuation', () => {
approvalPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand DownExpand Up@@ -249,7 +283,7 @@ describe('createMessageStream approval continuation', () => {
dynamicApprovalRespondedPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand All@@ -265,3 +299,151 @@ describe('createMessageStream approval continuation', () => {
});
});
});

const EPHEMERAL = { type: 'ephemeral' };

describe('createMessageStream prompt caching', () => {
test('marks the static system block for the Anthropic family', async () => {
const { system, messages } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// No repos / files / MCP tools → only the static system block.
expect(system).toHaveLength(1);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// The tail marker is applied per-step in prepareStep, not on the messages
// handed to streamText — those stay unmarked.
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
});

test('moves the tail marker onto the last message of each step via prepareStep', async () => {
const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});
expect(prepareStep).toBeTypeOf('function');

// Step 0: a single input message → marker lands on it.
const step0 = await prepareStep!({
steps: [],
stepNumber: 0,
model: {},
messages: [{ role: 'user', content: 'q' }],
});
expect(step0.messages?.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// Continuation step: the marker rides the NEW last message, and only it —
// earlier messages (including the prior tail) carry no marker.
const stepN = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'searching' },
{ role: 'assistant', content: 'tool output' },
],
});
const out = stepN.messages!;
expect(out[0].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out[1].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
});

test('prepareStep adds no tail marker for non-Anthropic providers', async () => {
// Force MCP so prepareStep exists even without a tail marker.
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: noopStrategy,
});
expect(prepareStep).toBeTypeOf('function');

const result = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'a' },
],
});

// activeTools still managed (MCP), but no message override / marker.
expect(result.messages).toBeUndefined();
expect(result.activeTools).toContain('tool_request_activation');
});

test('leaves the dynamic system block uncached', async () => {
const { system } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/repo'],
});

// Static checkpoint + dynamic (per-conversation) block.
expect(system).toHaveLength(2);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
expect(system[1].providerOptions).toBeUndefined();
expect(system[1].content).toContain('<selected_repositories>');
});

test('does not mark the tools block, so mid-run activeTools growth never busts it', async () => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// The static checkpoint sits on the system block (after the full tools
// section in render order), so no tool definition carries a breakpoint.
for (const tool of Object.values(tools)) {
expect(tool.providerOptions?.anthropic?.cacheControl).toBeUndefined();
}
});

test.each([
['non-Anthropic provider', () => getPromptCacheStrategy('openai', true)],
['caching disabled', () => getPromptCacheStrategy('anthropic', false)],
])('emits no cache markers for %s (multi-provider regression guard)', async (_label, makeStrategy) => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { system, messages, tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: makeStrategy(),
selectedRepos: ['github.com/acme/repo'],
});

for (const block of system) {
expect(block.providerOptions).toBeUndefined();
}
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
for (const tool of Object.values(tools)) {
expect(tool.providerOptions).toBeUndefined();
}
});

test('builds a byte-identical static prompt regardless of repos', async () => {
const first = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/one'],
});
const second = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/two', 'github.com/acme/three'],
});

expect(first.system[0].content).toBe(second.system[0].content);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)

### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -311,6 +311,14 @@ const options = {

SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
/** TTL for the static block. The moving tail marker always uses the 5m default. */
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
/**
* Observability: when enabled, logs a warning on unexpected prompt-cache
* breaks (static-prefix signature changes, or zero cache reads on a
* continuation step). Does not affect request behavior.
*/
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: booleanSchema.default('false'),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),

DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/app/api/(server)/ee/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { sew } from "@/middleware/sew";
import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from "@/ee/features/chat/askMcpAnalytics.server";
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
import { getLanguageModelKey } from "@/features/chat/utils";
import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
Expand DownExpand Up@@ -88,6 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);

// No-op for non-Anthropic providers / when caching is disabled, so
// it never perturbs other providers' requests.
const promptCacheStrategy = getPromptCacheStrategy(
languageModelConfig.provider,
env.SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED === 'true',
);

const expandedRepos = (await Promise.all(selectedSearchScopes.map(async (scope) => {
if (scope.type === 'repo') return [scope.value];
if (scope.type === 'reposet') {
Expand DownExpand Up@@ -131,6 +139,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
disabledMcpServerIds,
model,
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
promptCacheStrategy,
modelProviderOptions: providerOptions,
modelTemperature: temperature,
userId: user?.id,
Expand Down
192 changes: 187 additions & 5 deletions packages/web/src/ee/features/chat/agent.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ModelMessage } from 'ai';
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { SBChatMessage, SBChatMessagePart } from '@/features/chat/types';
import type { PromptCacheStrategy } from './promptCaching';

const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
Expand All@@ -25,6 +27,8 @@ vi.mock('@sourcebot/shared', () => ({
SOURCEBOT_CHAT_MAX_STEP_COUNT: 8,
SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0,
SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false',
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: '5m',
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: 'false',
},
getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@db.example.com:5432/sourcebot',
}));
Expand DownExpand Up@@ -94,6 +98,11 @@ vi.mock('ai', async (importOriginal) => {
});

const { createMessageStream } = await import('./agent');
const { getPromptCacheStrategy } = await import('./promptCaching');

// Strategies reused across the prompt-caching tests below.
const anthropicStrategy = getPromptCacheStrategy('anthropic', true);
const noopStrategy = getPromptCacheStrategy('openai', true);

const listReposInput = {
sort: 'name',
Expand DownExpand Up@@ -150,7 +159,29 @@ const createFakeStreamResult = () => ({
}),
});

const runCreateMessageStream = async (messages: SBChatMessage[]) => {
type FakePrepareStep = (opts: {
steps: Array<{ toolResults: Array<{ toolName: string; output: unknown }> }>;
stepNumber: number;
model: unknown;
messages: ModelMessage[];
}) =>
| { messages?: ModelMessage[]; activeTools?: string[] }
| Promise<{ messages?: ModelMessage[]; activeTools?: string[] }>;

interface StreamTextArgs {
messages: ModelMessage[];
system: Array<{ role: 'system'; content: string; providerOptions?: ProviderOptions }>;
tools: Record<string, { providerOptions?: ProviderOptions }>;
prepareStep?: FakePrepareStep;
}

const runCreateMessageStream = async (
messages: SBChatMessage[],
opts: {
promptCacheStrategy?: PromptCacheStrategy;
selectedRepos?: string[];
} = {},
): Promise<StreamTextArgs> => {
const convertedLastTurn: ModelMessage = {
role: 'assistant',
content: 'converted-last-turn',
Expand All@@ -161,10 +192,13 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
const props = {
chatId: 'chat-id',
messages,
selectedRepos: [],
selectedRepos: opts.selectedRepos ?? [],
prisma: {},
model: {},
modelName: 'test-model',
// Default to a no-op strategy so the approval-continuation tests below
// (which assert plain, unmarked messages) are unaffected by caching.
promptCacheStrategy: opts.promptCacheStrategy ?? noopStrategy,
onFinish: vi.fn(),
onError: () => 'error',
} as unknown as Parameters<typeof createMessageStream>[0];
Expand All@@ -188,7 +222,7 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
throw new Error('Expected streamText to be called with messages.');
}

return streamTextArgs.messages as ModelMessage[];
return streamTextArgs as StreamTextArgs;
};

beforeEach(() => {
Expand DownExpand Up@@ -216,7 +250,7 @@ describe('createMessageStream approval continuation', () => {
approvalPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand DownExpand Up@@ -249,7 +283,7 @@ describe('createMessageStream approval continuation', () => {
dynamicApprovalRespondedPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand All@@ -265,3 +299,151 @@ describe('createMessageStream approval continuation', () => {
});
});
});

const EPHEMERAL = { type: 'ephemeral' };

describe('createMessageStream prompt caching', () => {
test('marks the static system block for the Anthropic family', async () => {
const { system, messages } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// No repos / files / MCP tools → only the static system block.
expect(system).toHaveLength(1);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// The tail marker is applied per-step in prepareStep, not on the messages
// handed to streamText — those stay unmarked.
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
});

test('moves the tail marker onto the last message of each step via prepareStep', async () => {
const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});
expect(prepareStep).toBeTypeOf('function');

// Step 0: a single input message → marker lands on it.
const step0 = await prepareStep!({
steps: [],
stepNumber: 0,
model: {},
messages: [{ role: 'user', content: 'q' }],
});
expect(step0.messages?.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// Continuation step: the marker rides the NEW last message, and only it —
// earlier messages (including the prior tail) carry no marker.
const stepN = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'searching' },
{ role: 'assistant', content: 'tool output' },
],
});
const out = stepN.messages!;
expect(out[0].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out[1].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
});

test('prepareStep adds no tail marker for non-Anthropic providers', async () => {
// Force MCP so prepareStep exists even without a tail marker.
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: noopStrategy,
});
expect(prepareStep).toBeTypeOf('function');

const result = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'a' },
],
});

// activeTools still managed (MCP), but no message override / marker.
expect(result.messages).toBeUndefined();
expect(result.activeTools).toContain('tool_request_activation');
});

test('leaves the dynamic system block uncached', async () => {
const { system } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/repo'],
});

// Static checkpoint + dynamic (per-conversation) block.
expect(system).toHaveLength(2);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
expect(system[1].providerOptions).toBeUndefined();
expect(system[1].content).toContain('<selected_repositories>');
});

test('does not mark the tools block, so mid-run activeTools growth never busts it', async () => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// The static checkpoint sits on the system block (after the full tools
// section in render order), so no tool definition carries a breakpoint.
for (const tool of Object.values(tools)) {
expect(tool.providerOptions?.anthropic?.cacheControl).toBeUndefined();
}
});

test.each([
['non-Anthropic provider', () => getPromptCacheStrategy('openai', true)],
['caching disabled', () => getPromptCacheStrategy('anthropic', false)],
])('emits no cache markers for %s (multi-provider regression guard)', async (_label, makeStrategy) => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { system, messages, tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: makeStrategy(),
selectedRepos: ['github.com/acme/repo'],
});

for (const block of system) {
expect(block.providerOptions).toBeUndefined();
}
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
for (const tool of Object.values(tools)) {
expect(tool.providerOptions).toBeUndefined();
}
});

test('builds a byte-identical static prompt regardless of repos', async () => {
const first = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/one'],
});
const second = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/two', 'github.com/acme/three'],
});

expect(first.system[0].content).toBe(second.system[0].content);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)

### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -311,6 +311,14 @@ const options = {

SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
/** TTL for the static block. The moving tail marker always uses the 5m default. */
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
/**
* Observability: when enabled, logs a warning on unexpected prompt-cache
* breaks (static-prefix signature changes, or zero cache reads on a
* continuation step). Does not affect request behavior.
*/
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: booleanSchema.default('false'),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),

DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/app/api/(server)/ee/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { sew } from "@/middleware/sew";
import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from "@/ee/features/chat/askMcpAnalytics.server";
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
import { getLanguageModelKey } from "@/features/chat/utils";
import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
Expand DownExpand Up@@ -88,6 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);

// No-op for non-Anthropic providers / when caching is disabled, so
// it never perturbs other providers' requests.
const promptCacheStrategy = getPromptCacheStrategy(
languageModelConfig.provider,
env.SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED === 'true',
);

const expandedRepos = (await Promise.all(selectedSearchScopes.map(async (scope) => {
if (scope.type === 'repo') return [scope.value];
if (scope.type === 'reposet') {
Expand DownExpand Up@@ -131,6 +139,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
disabledMcpServerIds,
model,
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
promptCacheStrategy,
modelProviderOptions: providerOptions,
modelTemperature: temperature,
userId: user?.id,
Expand Down
192 changes: 187 additions & 5 deletions packages/web/src/ee/features/chat/agent.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ModelMessage } from 'ai';
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { SBChatMessage, SBChatMessagePart } from '@/features/chat/types';
import type { PromptCacheStrategy } from './promptCaching';

const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
Expand All@@ -25,6 +27,8 @@ vi.mock('@sourcebot/shared', () => ({
SOURCEBOT_CHAT_MAX_STEP_COUNT: 8,
SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0,
SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false',
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: '5m',
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: 'false',
},
getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@db.example.com:5432/sourcebot',
}));
Expand DownExpand Up@@ -94,6 +98,11 @@ vi.mock('ai', async (importOriginal) => {
});

const { createMessageStream } = await import('./agent');
const { getPromptCacheStrategy } = await import('./promptCaching');

// Strategies reused across the prompt-caching tests below.
const anthropicStrategy = getPromptCacheStrategy('anthropic', true);
const noopStrategy = getPromptCacheStrategy('openai', true);

const listReposInput = {
sort: 'name',
Expand DownExpand Up@@ -150,7 +159,29 @@ const createFakeStreamResult = () => ({
}),
});

const runCreateMessageStream = async (messages: SBChatMessage[]) => {
type FakePrepareStep = (opts: {
steps: Array<{ toolResults: Array<{ toolName: string; output: unknown }> }>;
stepNumber: number;
model: unknown;
messages: ModelMessage[];
}) =>
| { messages?: ModelMessage[]; activeTools?: string[] }
| Promise<{ messages?: ModelMessage[]; activeTools?: string[] }>;

interface StreamTextArgs {
messages: ModelMessage[];
system: Array<{ role: 'system'; content: string; providerOptions?: ProviderOptions }>;
tools: Record<string, { providerOptions?: ProviderOptions }>;
prepareStep?: FakePrepareStep;
}

const runCreateMessageStream = async (
messages: SBChatMessage[],
opts: {
promptCacheStrategy?: PromptCacheStrategy;
selectedRepos?: string[];
} = {},
): Promise<StreamTextArgs> => {
const convertedLastTurn: ModelMessage = {
role: 'assistant',
content: 'converted-last-turn',
Expand All@@ -161,10 +192,13 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
const props = {
chatId: 'chat-id',
messages,
selectedRepos: [],
selectedRepos: opts.selectedRepos ?? [],
prisma: {},
model: {},
modelName: 'test-model',
// Default to a no-op strategy so the approval-continuation tests below
// (which assert plain, unmarked messages) are unaffected by caching.
promptCacheStrategy: opts.promptCacheStrategy ?? noopStrategy,
onFinish: vi.fn(),
onError: () => 'error',
} as unknown as Parameters<typeof createMessageStream>[0];
Expand All@@ -188,7 +222,7 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
throw new Error('Expected streamText to be called with messages.');
}

return streamTextArgs.messages as ModelMessage[];
return streamTextArgs as StreamTextArgs;
};

beforeEach(() => {
Expand DownExpand Up@@ -216,7 +250,7 @@ describe('createMessageStream approval continuation', () => {
approvalPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand DownExpand Up@@ -249,7 +283,7 @@ describe('createMessageStream approval continuation', () => {
dynamicApprovalRespondedPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand All@@ -265,3 +299,151 @@ describe('createMessageStream approval continuation', () => {
});
});
});

const EPHEMERAL = { type: 'ephemeral' };

describe('createMessageStream prompt caching', () => {
test('marks the static system block for the Anthropic family', async () => {
const { system, messages } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// No repos / files / MCP tools → only the static system block.
expect(system).toHaveLength(1);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// The tail marker is applied per-step in prepareStep, not on the messages
// handed to streamText — those stay unmarked.
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
});

test('moves the tail marker onto the last message of each step via prepareStep', async () => {
const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});
expect(prepareStep).toBeTypeOf('function');

// Step 0: a single input message → marker lands on it.
const step0 = await prepareStep!({
steps: [],
stepNumber: 0,
model: {},
messages: [{ role: 'user', content: 'q' }],
});
expect(step0.messages?.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// Continuation step: the marker rides the NEW last message, and only it —
// earlier messages (including the prior tail) carry no marker.
const stepN = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'searching' },
{ role: 'assistant', content: 'tool output' },
],
});
const out = stepN.messages!;
expect(out[0].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out[1].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
});

test('prepareStep adds no tail marker for non-Anthropic providers', async () => {
// Force MCP so prepareStep exists even without a tail marker.
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: noopStrategy,
});
expect(prepareStep).toBeTypeOf('function');

const result = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'a' },
],
});

// activeTools still managed (MCP), but no message override / marker.
expect(result.messages).toBeUndefined();
expect(result.activeTools).toContain('tool_request_activation');
});

test('leaves the dynamic system block uncached', async () => {
const { system } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/repo'],
});

// Static checkpoint + dynamic (per-conversation) block.
expect(system).toHaveLength(2);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
expect(system[1].providerOptions).toBeUndefined();
expect(system[1].content).toContain('<selected_repositories>');
});

test('does not mark the tools block, so mid-run activeTools growth never busts it', async () => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// The static checkpoint sits on the system block (after the full tools
// section in render order), so no tool definition carries a breakpoint.
for (const tool of Object.values(tools)) {
expect(tool.providerOptions?.anthropic?.cacheControl).toBeUndefined();
}
});

test.each([
['non-Anthropic provider', () => getPromptCacheStrategy('openai', true)],
['caching disabled', () => getPromptCacheStrategy('anthropic', false)],
])('emits no cache markers for %s (multi-provider regression guard)', async (_label, makeStrategy) => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { system, messages, tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: makeStrategy(),
selectedRepos: ['github.com/acme/repo'],
});

for (const block of system) {
expect(block.providerOptions).toBeUndefined();
}
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
for (const tool of Object.values(tools)) {
expect(tool.providerOptions).toBeUndefined();
}
});

test('builds a byte-identical static prompt regardless of repos', async () => {
const first = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/one'],
});
const second = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/two', 'github.com/acme/three'],
});

expect(first.system[0].content).toBe(second.system[0].content);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)

### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)

Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -311,6 +311,14 @@ const options = {

SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
/** TTL for the static block. The moving tail marker always uses the 5m default. */
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
/**
* Observability: when enabled, logs a warning on unexpected prompt-cache
* breaks (static-prefix signature changes, or zero cache reads on a
* continuation step). Does not affect request behavior.
*/
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: booleanSchema.default('false'),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),

DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/app/api/(server)/ee/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { sew } from "@/middleware/sew";
import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from "@/ee/features/chat/askMcpAnalytics.server";
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
import { getLanguageModelKey } from "@/features/chat/utils";
import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
Expand DownExpand Up@@ -88,6 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);

// No-op for non-Anthropic providers / when caching is disabled, so
// it never perturbs other providers' requests.
const promptCacheStrategy = getPromptCacheStrategy(
languageModelConfig.provider,
env.SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED === 'true',
);

const expandedRepos = (await Promise.all(selectedSearchScopes.map(async (scope) => {
if (scope.type === 'repo') return [scope.value];
if (scope.type === 'reposet') {
Expand DownExpand Up@@ -131,6 +139,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
disabledMcpServerIds,
model,
modelName: languageModelConfig.displayName ?? languageModelConfig.model,
promptCacheStrategy,
modelProviderOptions: providerOptions,
modelTemperature: temperature,
userId: user?.id,
Expand Down
192 changes: 187 additions & 5 deletions packages/web/src/ee/features/chat/agent.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ModelMessage } from 'ai';
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import type { SBChatMessage, SBChatMessagePart } from '@/features/chat/types';
import type { PromptCacheStrategy } from './promptCaching';

const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
Expand All@@ -25,6 +27,8 @@ vi.mock('@sourcebot/shared', () => ({
SOURCEBOT_CHAT_MAX_STEP_COUNT: 8,
SOURCEBOT_CHAT_MODEL_TEMPERATURE: 0,
SOURCEBOT_TELEMETRY_PII_COLLECTION_ENABLED: 'false',
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: '5m',
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: 'false',
},
getDBConnectionString: () => 'postgresql://sourcebot:sourcebot@db.example.com:5432/sourcebot',
}));
Expand DownExpand Up@@ -94,6 +98,11 @@ vi.mock('ai', async (importOriginal) => {
});

const { createMessageStream } = await import('./agent');
const { getPromptCacheStrategy } = await import('./promptCaching');

// Strategies reused across the prompt-caching tests below.
const anthropicStrategy = getPromptCacheStrategy('anthropic', true);
const noopStrategy = getPromptCacheStrategy('openai', true);

const listReposInput = {
sort: 'name',
Expand DownExpand Up@@ -150,7 +159,29 @@ const createFakeStreamResult = () => ({
}),
});

const runCreateMessageStream = async (messages: SBChatMessage[]) => {
type FakePrepareStep = (opts: {
steps: Array<{ toolResults: Array<{ toolName: string; output: unknown }> }>;
stepNumber: number;
model: unknown;
messages: ModelMessage[];
}) =>
| { messages?: ModelMessage[]; activeTools?: string[] }
| Promise<{ messages?: ModelMessage[]; activeTools?: string[] }>;

interface StreamTextArgs {
messages: ModelMessage[];
system: Array<{ role: 'system'; content: string; providerOptions?: ProviderOptions }>;
tools: Record<string, { providerOptions?: ProviderOptions }>;
prepareStep?: FakePrepareStep;
}

const runCreateMessageStream = async (
messages: SBChatMessage[],
opts: {
promptCacheStrategy?: PromptCacheStrategy;
selectedRepos?: string[];
} = {},
): Promise<StreamTextArgs> => {
const convertedLastTurn: ModelMessage = {
role: 'assistant',
content: 'converted-last-turn',
Expand All@@ -161,10 +192,13 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
const props = {
chatId: 'chat-id',
messages,
selectedRepos: [],
selectedRepos: opts.selectedRepos ?? [],
prisma: {},
model: {},
modelName: 'test-model',
// Default to a no-op strategy so the approval-continuation tests below
// (which assert plain, unmarked messages) are unaffected by caching.
promptCacheStrategy: opts.promptCacheStrategy ?? noopStrategy,
onFinish: vi.fn(),
onError: () => 'error',
} as unknown as Parameters<typeof createMessageStream>[0];
Expand All@@ -188,7 +222,7 @@ const runCreateMessageStream = async (messages: SBChatMessage[]) => {
throw new Error('Expected streamText to be called with messages.');
}

return streamTextArgs.messages as ModelMessage[];
return streamTextArgs as StreamTextArgs;
};

beforeEach(() => {
Expand DownExpand Up@@ -216,7 +250,7 @@ describe('createMessageStream approval continuation', () => {
approvalPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand DownExpand Up@@ -249,7 +283,7 @@ describe('createMessageStream approval continuation', () => {
dynamicApprovalRespondedPart,
]);

const streamTextMessages = await runCreateMessageStream([
const { messages: streamTextMessages } = await runCreateMessageStream([
createUserMessage(),
assistantMessage,
]);
Expand All@@ -265,3 +299,151 @@ describe('createMessageStream approval continuation', () => {
});
});
});

const EPHEMERAL = { type: 'ephemeral' };

describe('createMessageStream prompt caching', () => {
test('marks the static system block for the Anthropic family', async () => {
const { system, messages } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// No repos / files / MCP tools → only the static system block.
expect(system).toHaveLength(1);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// The tail marker is applied per-step in prepareStep, not on the messages
// handed to streamText — those stay unmarked.
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
});

test('moves the tail marker onto the last message of each step via prepareStep', async () => {
const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});
expect(prepareStep).toBeTypeOf('function');

// Step 0: a single input message → marker lands on it.
const step0 = await prepareStep!({
steps: [],
stepNumber: 0,
model: {},
messages: [{ role: 'user', content: 'q' }],
});
expect(step0.messages?.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);

// Continuation step: the marker rides the NEW last message, and only it —
// earlier messages (including the prior tail) carry no marker.
const stepN = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'searching' },
{ role: 'assistant', content: 'tool output' },
],
});
const out = stepN.messages!;
expect(out[0].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out[1].providerOptions?.anthropic?.cacheControl).toBeUndefined();
expect(out.at(-1)?.providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
});

test('prepareStep adds no tail marker for non-Anthropic providers', async () => {
// Force MCP so prepareStep exists even without a tail marker.
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { prepareStep } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: noopStrategy,
});
expect(prepareStep).toBeTypeOf('function');

const result = await prepareStep!({
steps: [],
stepNumber: 1,
model: {},
messages: [
{ role: 'user', content: 'q' },
{ role: 'assistant', content: 'a' },
],
});

// activeTools still managed (MCP), but no message override / marker.
expect(result.messages).toBeUndefined();
expect(result.activeTools).toContain('tool_request_activation');
});

test('leaves the dynamic system block uncached', async () => {
const { system } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/repo'],
});

// Static checkpoint + dynamic (per-conversation) block.
expect(system).toHaveLength(2);
expect(system[0].providerOptions?.anthropic?.cacheControl).toEqual(EPHEMERAL);
expect(system[1].providerOptions).toBeUndefined();
expect(system[1].content).toContain('<selected_repositories>');
});

test('does not mark the tools block, so mid-run activeTools growth never busts it', async () => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
});

// The static checkpoint sits on the system block (after the full tools
// section in render order), so no tool definition carries a breakpoint.
for (const tool of Object.values(tools)) {
expect(tool.providerOptions?.anthropic?.cacheControl).toBeUndefined();
}
});

test.each([
['non-Anthropic provider', () => getPromptCacheStrategy('openai', true)],
['caching disabled', () => getPromptCacheStrategy('anthropic', false)],
])('emits no cache markers for %s (multi-provider regression guard)', async (_label, makeStrategy) => {
const { buildMcpToolRegistry } = await import('@/ee/features/chat/mcp/mcpToolRegistry');
vi.mocked(buildMcpToolRegistry).mockReturnValueOnce([
{ name: 'mcp_linear__save_issue', description: 'Save an issue', serverName: 'linear' },
]);

const { system, messages, tools } = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: makeStrategy(),
selectedRepos: ['github.com/acme/repo'],
});

for (const block of system) {
expect(block.providerOptions).toBeUndefined();
}
for (const message of messages) {
expect(message.providerOptions).toBeUndefined();
}
for (const tool of Object.values(tools)) {
expect(tool.providerOptions).toBeUndefined();
}
});

test('builds a byte-identical static prompt regardless of repos', async () => {
const first = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/one'],
});
const second = await runCreateMessageStream([createUserMessage()], {
promptCacheStrategy: anthropicStrategy,
selectedRepos: ['github.com/acme/two', 'github.com/acme/three'],
});

expect(first.system[0].content).toBe(second.system[0].content);
});
});
Loading
Loading