Path: blob/main/src/vs/platform/agentHost/node/claude/phase-plan.1.5.md
13399 views
Phase 1.5 Implementation Plan — Widen ICopilotApiService to Raw Anthropic Events
Council-synthesized plan (GPT + Opus + Gemini). All architectural decisions are final. Implement as a single atomic PR on top of the Phase 1 commit.
Overview
| File | Change type |
|---|---|
/package.json | Add @anthropic-ai/sdk@^0.82.0 dependency |
src/vs/platform/agentHost/node/shared/copilotApiService.ts | Drop custom types; rewrite interface + implementation |
src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts | Migrate 80 existing tests + add ~15 new |
Finalized decisions (do not re-litigate)
@anthropic-ai/sdk@^0.82.0goes in rootpackage.jsondependencies.Drop
ICopilotMessagesRequest,ICopilotChatMessage,ICopilotMessagesResponseentirely.Request type: use SDK's discriminated
Anthropic.MessageCreateParamsStreaming/MessageCreateParamsNonStreaming(and theMessageCreateParamsunion for the implementation signature) — notMessageCreateParamsBase.New
ICopilotApiServiceRequestOptions { headers?, signal? }replaces the positionalsignaleverywhere.messages()streaming →AsyncGenerator<Anthropic.MessageStreamEvent>.messages()non-streaming →Promise<Anthropic.Message>.messagesText()wrapsmessages(), filters totext_delta.textstrings.Cut — no downstream phase consumes it. This is a greenfield service; callers can filtermessages()in a few lines if needed.countTokens()throws'countTokens not supported by CAPI'immediately.message_stopis yielded as the last event before the generator returns.All 80 tests migrate in the same PR.
Step 0 — package.json
File: /package.json line ~92 (after @anthropic-ai/sandbox-runtime)
Run npm install to update the lockfile.
Step 1 — Drop custom types, add import and options type
File: src/vs/platform/agentHost/node/shared/copilotApiService.ts
1-A. Add SDK import (top of file, after existing imports ~line 6)
1-B. Delete the three custom types in // #region Types (lines 15–51)
Delete
ICopilotChatMessage(lines ~18–21)Delete
ICopilotMessagesRequest(lines ~22–38, including doc comment)Delete
ICopilotMessagesResponse(lines ~40–51, including doc comment)
1-C. Add ICopilotApiServiceRequestOptions in their place
Surviving types (ICopilotTokenEnvelope, ICachedToken, ICapiInit) stay untouched.
Step 2 — Rewrite ICopilotApiService interface (lines ~142–180)
Replace the entire interface body:
Step 3 — Rewrite CopilotApiService public methods (lines ~199–237)
3-A. messages() overloads + dispatch
3-B. messagesText()
3-C. countTokens()
3-D. models() — change signal? → options?
Inside: replace all signal references with options?.signal. The request header block stays; merge options?.headers before Authorization:
Step 4 — Rewrite private streaming/non-streaming helpers
4-A. _messagesStreaming (line ~295)
4-B. _messagesNonStreaming (line ~309)
Remove all text-concatenation logic that was in this method.
4-C. _sendRequest (line ~330) — new signature + body builder
Body construction — spread the SDK-shaped request, inject stream, normalize system:
Headers — merge caller headers first so security-sensitive ones always win:
Replace all internal signal references with options?.signal. Pass options?.signal to _getCopilotToken.
4-D. _readSSE (line ~447) — change return type
Loop logic: if the event returned by _parseDataLine has type === 'message_stop', yield it and then return.
4-E. _parseDataLine (line ~497) — yield full events
Change return type from string | undefined | null to Anthropic.MessageStreamEvent | undefined:
Update _readSSE caller:
The null-sentinel pattern is removed. message_stop is yielded, then return triggers finally.
Step 5 — Migrate existing 80 tests
File: src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts
5-A. Imports — replace ICopilotChatMessage with Anthropic SDK type
5-B. collect<T>() helper — make generic (line ~34)
No other change to collect — it now works for both string[] (via messagesText) and Anthropic.MessageStreamEvent[] (via messages).
5-C. Base request fixture — rename fields to SDK snake_case (line ~100)
Remove headers from baseRequest — headers are now in options.
5-D. ICopilotChatMessage[] usage in tests (lines ~99, ~518)
Replace with Anthropic.MessageParam[]:
The values { role: 'user', content: '...' } are valid MessageParam — no shape change needed.
5-E. Request Format suite (lines ~421–581)
maxTokens→max_tokensin any inline request objects.Custom headers move from request body to
options:systemtest (line ~423): CAPI still expects the array format; the_sendRequestbody builder handles the normalization. The assertionsystem: [{ type: 'text', text: ... }]stays correct.streamflag test: stays on the streaming call;{ ...baseRequest, stream: true as const }is still valid sincebaseRequest.streamisfalse as constand the spread overrides it.
5-F. Non-Streaming suite (lines ~588–682)
Add a local helper for extracting text from Anthropic.Message:
Per-test changes:
| Old assertion | New assertion |
|---|---|
result.content === 'The answer is 42.' | getText(result) === 'The answer is 42.' |
result.content === 'First part. Second part.' | getText(result) === 'First part. Second part.' |
"skips non-text blocks" → result.content === 'the answer' | getText(result) === 'the answer' (non-text blocks are in result.content but getText filters them) |
result.content === '' | getText(result) === '' |
result.stopReason === 'max_tokens' | result.stop_reason === 'max_tokens' |
result.stopReason === 'unknown' when missing | Rename test: assert result.stop_reason == null |
Error tests (429, 500) — no change.
5-G. Streaming suite (lines ~688–956) — all text-asserting tests move to messagesText()
Mechanical find-replace within this suite:
This applies to all tests asserting string[] values. The assertions themselves are unchanged.
Keep on messages() (not messagesText):
Error-throwing tests (SSE
errorevent, non-200, missing body) — testing transport errors, not text extractionCancellation stream tests (lines ~1057–1133) — testing
reader.cancel(), not yielded values
5-H. Cancellation suite — signal positional → options.signal (lines ~1009–1055)
Step 6 — Add new tests (~15)
Add these three suites after the existing "Streaming Responses" suite.
Suite: Raw Event Stream (messages())
Suite: messagesText()
Suite: countTokens
Step 7 — Verify
Key risks
| Risk | Where | Mitigation |
|---|---|---|
_sendRequest still cherry-picks fields | copilotApiService.ts:337–343 | Must spread ...rest from SDK request; delete old remapping |
system field double-wrapping | _sendRequest body builder | Keep explicit typeof system === 'string' normalization; CAPI requires array format |
Positional signal not fully migrated | All call sites in service + tests | Grep for , signal and , controller.signal before declaring done |
Non-streaming tests asserting result.content (string) | Lines ~590–657 | All must switch to getText(result) helper |
message_stop sentinel pattern removal | _parseDataLine / _readSSE | Remove null return; yield event, then return; finally still calls reader.cancel() |
Learnings (from council review post-implementation)
Buffer-flush path must mirror the main loop's
message_stopearly-return guard._readSSEhas two paths that yield events: the mainwhileloop (lines ~500–509) and a buffer-flush after the loop (lines ~511–516). The main loop correctlyreturns after yieldingmessage_stop. The buffer-flush initially did not — it would yieldmessage_stopbut fall through naturally. Functionally identical today, but asymmetric: if someone later adds logic after the flush yield, they'd get events aftermessage_stop. Always mirror the early-return guard in both paths. Fix: addif (event.type === 'message_stop') return;after the buffer-flushyield event.New raw-event suites must include a
tool_useblock round-trip test. The roadmap explicitly requires testing thattool_useevents pass throughmessages(). The initial implementation's raw-event suite only covered lifecycle,thinking_delta,input_json_delta, andmessage_delta.tool_usewas omitted. Whenever adding new streaming suites, check the roadmap's "Tests" section for explicitly listed cases and add them all.Security-header override invariants need a separate test per method. The
messages()method had a test (line ~571) verifying callers cannot overrideAuthorization,Content-Type, etc. Themodels()method did not — even though its implementation is also correct. The lack of a test means a future refactor could silently regress the security invariant. Add an equivalent header-override test for every public method that makes authenticated requests.
Learnings (from simplify review post-implementation)
MessageCreateParamsBaseis not re-exported on theAnthropicnamespace. The plan specifiedAnthropic.MessageCreateParamsBaseas the request type, but it's an SDK internal. Use the discriminated forms:Anthropic.MessageCreateParamsStreaming/Anthropic.MessageCreateParamsNonStreamingfor the overloads, and theAnthropic.MessageCreateParamsunion for the implementation signature.VS Code DI constructor ordering: non-service params must come first.
GetLeadingNonServiceArgsstripsBrandedService-decorated params from the end of the tuple. Putting non-service params (likefetchFn) after service params causescreateInstanceto select the wrong overload (Expected 4 arguments, but got 2). This contradicts the CLAUDE.md guidance that "non-service parameters come after service parameters."Signal must not be shared across deduped async operations. The original
_getCopilotTokenforwarded the caller'sAbortSignalinto the shared token mint promise. Because the mint is deduped across concurrent callers via_pendingTokenMints, aborting one caller's signal would cancel the mint for all callers sharing it. Fix: omit the signal from the mint call entirely; each caller forwards its signal only to its own API request.Avoid
as unknown asfor SSE event parsing — use a runtime type guard. Instead ofparsed as unknown as Anthropic.MessageStreamEvent, validate thetypefield against aSetof known SSE event types (message_start,message_delta,message_stop,content_block_start,content_block_delta,content_block_stop). This cleanly separateserrorevent handling (which should throw) from valid events, and avoids TypeScript error TS2367 when comparing against values not in the target type.messagesText()was YAGNI. No downstream phase in the roadmap consumes a text-only streaming API. This is a greenfield service with no backcompat obligations. Cut it rather than carry dead surface area. If a future caller needs text-only streaming, filteringmessages()output is trivial.@vscode/copilot-apiambient typings must avoidany. The package uses extensionless relative imports incompatible withmoduleResolution: "nodenext", requiring ambient declarations insrc/typings/copilot-api.d.ts. The original declarations usedjson?: anyandPromise<any>— replaced withunknownper project coding guidelines.Use
Iterable.asyncToArrayfrombase/common/iteratorinstead of a localcollect<T>helper. The codebase already has this utility; no need to duplicate it in tests.