Skip to content

Commit d310099

Browse files
committed
fix: handle websocket and tool result cache controls
1 parent 5a3ab0a commit d310099

5 files changed

Lines changed: 163 additions & 7 deletions

File tree

src/lib/api-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,8 @@ export const copilotWebSocketHeaders = (
370370
"Copilot-Integration-Id": source("copilot-integration-id", "vscode-chat"),
371371
})
372372

373+
setPreparedHeader(headers, "host", preparedHeaders, "host")
374+
373375
setPreparedHeader(
374376
headers,
375377
"Copilot-Vision-Request",

src/routes/messages/preprocess.ts

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
AnthropicMessagesPayload,
2222
AnthropicTextBlock,
2323
AnthropicToolResultBlock,
24+
AnthropicToolResultContentBlock,
2425
AnthropicUserContentBlock,
2526
} from "./anthropic-types"
2627

@@ -185,32 +186,112 @@ const mergeContentWithText = (
185186
tr: AnthropicToolResultBlock,
186187
textBlock: AnthropicTextBlock,
187188
): AnthropicToolResultBlock => {
189+
const normalizedToolResult = normalizeToolResultContentCacheControl(tr)
190+
const mergedToolResult = mergeToolResultCacheControl(normalizedToolResult, [
191+
textBlock,
192+
])
193+
const sanitizedTextBlock = stripContentBlockCacheControl(textBlock)
194+
188195
if (typeof tr.content === "string") {
189-
return { ...tr, content: `${tr.content}\n\n${textBlock.text}` }
196+
return {
197+
...mergedToolResult,
198+
content: `${tr.content}\n\n${textBlock.text}`,
199+
}
190200
}
191201
// Unable to merge, discard other text blocks, wait for the next round of re-request
192202
if (hasToolRef(tr)) {
193203
return tr
194204
}
195205
return {
196-
...tr,
197-
content: [...tr.content, textBlock],
206+
...mergedToolResult,
207+
content: [...normalizedToolResult.content, sanitizedTextBlock],
198208
}
199209
}
200210

201211
const mergeContentWithTexts = (
202212
tr: AnthropicToolResultBlock,
203213
textBlocks: Array<AnthropicTextBlock>,
204214
): AnthropicToolResultBlock => {
215+
const normalizedToolResult = normalizeToolResultContentCacheControl(tr)
216+
const mergedToolResult = mergeToolResultCacheControl(
217+
normalizedToolResult,
218+
textBlocks,
219+
)
220+
const sanitizedTextBlocks = textBlocks.map(stripContentBlockCacheControl)
221+
205222
if (typeof tr.content === "string") {
206223
const appendedTexts = textBlocks.map((tb) => tb.text).join("\n\n")
207-
return { ...tr, content: `${tr.content}\n\n${appendedTexts}` }
224+
return {
225+
...mergedToolResult,
226+
content: `${tr.content}\n\n${appendedTexts}`,
227+
}
208228
}
209229
// Unable to merge, discard other text blocks, wait for the next round of re-request
210230
if (hasToolRef(tr)) {
211231
return tr
212232
}
213-
return { ...tr, content: [...tr.content, ...textBlocks] }
233+
return {
234+
...mergedToolResult,
235+
content: [...normalizedToolResult.content, ...sanitizedTextBlocks],
236+
}
237+
}
238+
239+
const mergeToolResultCacheControl = (
240+
tr: AnthropicToolResultBlock,
241+
textBlocks: Array<AnthropicTextBlock>,
242+
): AnthropicToolResultBlock => {
243+
const cacheControl = textBlocks
244+
.map((block) => block.cache_control)
245+
.findLast(
246+
(candidate): candidate is AnthropicCacheControl =>
247+
Boolean(candidate) && typeof candidate === "object",
248+
)
249+
250+
if (cacheControl) {
251+
return { ...tr, cache_control: { ...cacheControl } }
252+
}
253+
254+
return tr.cache_control ?
255+
{ ...tr, cache_control: { ...tr.cache_control } }
256+
: tr
257+
}
258+
259+
const stripContentBlockCacheControl = (
260+
block: AnthropicTextBlock,
261+
): AnthropicTextBlock => {
262+
if (!block.cache_control) {
263+
return block
264+
}
265+
266+
return {
267+
text: block.text,
268+
type: "text",
269+
}
270+
}
271+
272+
const normalizeToolResultContentCacheControl = (
273+
tr: AnthropicToolResultBlock,
274+
): AnthropicToolResultBlock & {
275+
content: Array<AnthropicToolResultContentBlock>
276+
} => {
277+
if (typeof tr.content === "string") {
278+
return { ...tr, content: [{ type: "text", text: tr.content }] }
279+
}
280+
281+
let cacheControl = tr.cache_control
282+
const content = tr.content.map((block) => {
283+
if (!("cache_control" in block) || !block.cache_control) {
284+
return block
285+
}
286+
287+
cacheControl = block.cache_control
288+
const { cache_control: _cacheControl, ...rest } = block
289+
return rest
290+
})
291+
292+
return cacheControl ?
293+
{ ...tr, cache_control: cacheControl, content }
294+
: { ...tr, content }
214295
}
215296

216297
const mergeContentWithAttachments = (

tests/create-responses.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ describe("createResponses websocket helpers", () => {
459459
"Editor-Device-Id": "device-1",
460460
"Editor-Plugin-Version": "copilot-chat/0.47.1",
461461
"Editor-Version": "vscode/1.120.0",
462+
host: "api.githubcopilot.com",
462463
"OpenAI-Intent": "conversation-agent",
463464
"VScode-SessionId": "session-1",
464465
"VScode-MachineId": "machine-1",

tests/messages-preprocess.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,66 @@ describe("mergeToolResultForClaude", () => {
251251
})
252252
})
253253

254+
test("lifts cache_control out of merged array tool_result content", () => {
255+
const payload: AnthropicMessagesPayload = {
256+
model: "claude-opus-4.6",
257+
max_tokens: 128,
258+
messages: [
259+
{
260+
role: "user",
261+
content: [
262+
{
263+
type: "tool_result",
264+
tool_use_id: "tool-1",
265+
content: [
266+
{
267+
type: "text",
268+
text: "tool output",
269+
cache_control: {
270+
type: "ephemeral",
271+
ttl: "1h",
272+
},
273+
},
274+
],
275+
},
276+
{
277+
type: "text",
278+
text: "cached trailing text",
279+
cache_control: {
280+
type: "ephemeral",
281+
},
282+
},
283+
],
284+
},
285+
],
286+
}
287+
288+
mergeToolResultForClaude(payload)
289+
290+
expect(payload.messages[0]).toEqual({
291+
role: "user",
292+
content: [
293+
{
294+
type: "tool_result",
295+
tool_use_id: "tool-1",
296+
content: [
297+
{
298+
type: "text",
299+
text: "tool output",
300+
},
301+
{
302+
type: "text",
303+
text: "cached trailing text",
304+
},
305+
],
306+
cache_control: {
307+
type: "ephemeral",
308+
},
309+
},
310+
],
311+
})
312+
})
313+
254314
test("appends all text blocks to the last tool_result when counts differ", () => {
255315
const payload: AnthropicMessagesPayload = {
256316
model: "claude-opus-4.6",

tests/telemetry.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,8 +440,20 @@ describe("telemetry: request/response wrappers", () => {
440440
trackPanelRequest({ headerRequestId: "req-panel-001" })
441441
await flushMicrotasks()
442442

443-
expect(requestMock).toHaveBeenCalledTimes(1)
444-
const call = requestMock.mock.calls[0] as Array<unknown> | undefined
443+
const requestCalls = requestMock.mock.calls as Array<Array<unknown>>
444+
const call = requestCalls.find((candidate) => {
445+
const options = candidate[1]
446+
if (typeof options !== "object" || options === null) {
447+
return false
448+
}
449+
450+
const body = (options as { body?: unknown }).body
451+
if (typeof body !== "string") {
452+
return false
453+
}
454+
455+
return body.includes('"headerRequestId":"req-panel-001"')
456+
})
445457
if (!call) {
446458
throw new TypeError("request call missing")
447459
}

0 commit comments

Comments
 (0)