Compare commits

...
Author SHA1 Message Date
Max Paulus 🥪 fbe0275d5d fix sdk initial messages 2026-04-20 14:10:10 -07:00
Max Paulus 🥪 bd52ebd6e4 updated problems.md 2026-04-20 13:52:35 -07:00
Max Paulus 🥪 26614a0079 fix read_files tool call not showing all file paths. Fixed assistant message not appearing after tool call result 2026-04-20 13:26:41 -07:00
Max Paulus 🥪 bc35905345 cline chatview is able to show some tool calls 2026-04-20 13:07:44 -07:00
Max Paulus 🥪 b2db4937a9 fix(sdk): preserve task session id when reloading MCP tools
Keep the active task/session id stable during MCP tool-list restarts so currentTaskItem stays mapped and chat state is not lost after toggling MCP servers.
2026-04-20 12:22:18 -07:00
Max Paulus 🥪 b807f5b5db refactor some sdk session code (DRY it up a bit) 2026-04-20 11:35:10 -07:00
Max Paulus 🥪 70b5ff1102 fix (click new task while in mid task)
- fixes the issue where if I click new task while mid task, and navigate
back to old task, old task still shows as thinking
2026-04-20 11:09:52 -07:00
Max Paulus 🥪 34afde1c54 resume session working
- new task button doesn't work thoguh
2026-04-20 10:50:21 -07:00
Max Paulus 🥪 491812227e todo session resume 2026-04-20 10:04:46 -07:00
Max Paulus 🥪 69d500f876 handle hicap and requesty auth callback support 2026-04-20 09:44:48 -07:00
Max Paulus 🥪 d68e839817 add openrouter auth callback support
- tested by choosing openrouter provider, clicking "get openrouter api
key", and then sending a prompt to openrouter (gpt-oss-120b:free model)
2026-04-20 09:36:40 -07:00
Max Paulus 🥪 a8ac26e363 fix mcp oauth callback
- tests: tested with remote notion mcp: https://mcp.notion.com/mcp
2026-04-20 09:18:19 -07:00
Dominic Cooney 1c1b1b0150 docs: add S6-35 — inference cost not displayed in task (minor) 2026-04-17 21:01:26 +09:00
Dominic Cooney 2e1e11e7b0 docs: add S6-34 — cancel during generation doesn't show Resume task 2026-04-17 21:01:26 +09:00
Dominic Cooney 3de89adad8 docs: add S6-33 — insufficient credits shows raw error instead of buy-credits UI 2026-04-17 21:01:26 +09:00
Dominic Cooney 51dd7056c2 docs: add S6-32 — New Task button and delete disabled after MCP tool change 2026-04-17 21:01:26 +09:00
Dominic Cooney 586ea66e94 docs: add S6-31 — conversation history lost after MCP tool changes 2026-04-17 21:01:26 +09:00
Dominic Cooney a28894e8b3 fix(sdk): update adapter for SDK sync (b2f9f62d → e99831a6)
SessionManager interface changes:
- Add update() and handleHookEvent() to VscodeSessionHost
- Remove readHooks() (no longer in SessionManager interface)
- Import HookEventPayload from @clinebot/core

Auth service fixes:
- Replace InstanceType<typeof UserInfo> with UserInfo type directly
- Fix null assignment to protobuf field (use empty create instead)
2026-04-17 21:01:26 +09:00
Dominic Cooney 62263ad1a0 feat(sdk): add attempt_completion tool and fix duplicate green rectangles
In the SDK migration branch, every agent response was showing a green
'Task Completed' rectangle because the done event unconditionally emitted
completion_result messages. In the classic extension, the green rectangle
only appeared when the agent explicitly called the attempt_completion tool.

Changes:
- Register attempt_completion as a custom tool in VscodeRuntimeBuilder
  so the SDK agent can call it (the SDK has no built-in equivalent)
- Handle attempt_completion in MessageTranslator: content_start emits
  say:'completion_result' (green rectangle), content_end emits
  ask:'completion_result' with empty text (enables follow-up input
  without a second green rectangle)
- Track attempt_completion calls via MessageTranslatorState so the done
  event can skip emitting completion_result when already handled
- When attempt_completion is NOT called, done emits ask:'completion_result'
  with empty text (renders as InvisibleSpacer, no green rectangle)
- Update tests: 2 existing tests updated, 1 new test added for the
  suppression behavior (35/35 pass)
2026-04-17 21:01:26 +09:00
Dominic Cooney 3e1452578d docs: update PROBLEMS.md — S6-29 verified fixed, remove from priority list 2026-04-17 21:01:25 +09:00
Dominic Cooney 5e67494ff0 fix(S6-29): emit completion_result after MCP tool reload
After restartSessionForMcpTools() completes, the webview was left in
a 'Thinking...' state because no ask:'completion_result' message was
emitted. The webview's handleSendMessage() requires clineAsk to be
set to enable follow-up input.

Fix: Emit ask:'completion_result' after the success info message so
the webview knows the agent is idle and enables the follow-up input.

Tested with debug harness:
1. Sent 'Say hello briefly' → completed
2. Toggled kamibiki MCP server off via UI
3. 'MCP tools changed' + 'reloaded successfully' messages appeared
4. Typed 'Say goodbye' → follow-up inference ran successfully
2026-04-17 21:01:25 +09:00
Dominic Cooney 55e4938d6c docs: update PROBLEMS.md — verify MCP tools, follow-up messages, debouncing
Mark as verified:
- S6-10: MCP tools work via VscodeRuntimeBuilder + McpHub bridge
- S6-14: VscodeRuntimeBuilder bridges all transport types
- S6-20: MCP tools available to agent (kb_status, kb_search tested)
- S6-28: MCP tool reload debouncing prevents duplicate messages
- S6-30: New — follow-up messages fixed (say→ask completion_result)
2026-04-17 21:01:25 +09:00
Dominic Cooney e6c0898924 fix: MCP tools + follow-up messages in SDK migration
Three fixes for the SDK migration:

1. Follow-up messages: Changed the 'done' event translation from
   say:'completion_result' to ask:'completion_result'. The webview's
   handleSendMessage() requires clineAsk to be set to send follow-up
   messages. Without the ask message, typing a follow-up and pressing
   Enter was silently dropped.

2. Session cleanup: Clear activeSession reference before stop/dispose
   to prevent re-entrant calls. Added 3s timeouts to stop()/dispose()
   to prevent UI blocking when sessions are in unexpected states.

3. MCP tool change debouncing: When an MCP server connects,
   notifyWebviewOfServerChanges() fires multiple times in quick
   succession. Added 300ms debounce with fingerprint quick-check
   to coalesce these into a single tool list change callback.

Tested with debug harness:
- MCP tools (kb_status, kb_search) work correctly
- Follow-up messages work via both gRPC and DOM (typing + Enter)
- MCP tools work in follow-up turns
2026-04-17 21:01:25 +09:00
Dominic Cooney 6f22f86270 feat: MCP tool list change detection and session restart
- McpHub: Added computeToolFingerprint(), setToolListChangeCallback(),
  clearToolListChangeCallback(), and checkToolListChanged() to detect
  when the set of available MCP tools changes (servers added/removed/
  reconnected). Only fires on actual tool list changes, not mere status
  updates.

- SdkController: Added handleMcpToolListChanged() which restarts the
  session immediately when idle, or defers via mcpToolRestartPending
  flag until the current turn completes. restartSessionForMcpTools()
  creates a new VscodeSessionHost with fresh tools, preserves
  conversation messages, and emits info messages to the chat.

- SdkController: Fixed MCP settings file path — was reading from
  VSCode extension storage (HostProvider.globalStorageFsPath/settings/)
  instead of ~/.cline/data/settings/ where the actual MCP settings live.

- task-proxy: Made taskId settable so session restart can update the
  proxy's session ID without recreating it.

- Tests: 16 unit tests for tool list change detection covering
  fingerprinting, callback firing, and edge cases.

Known issues: S6-28 (reload messages appear twice), S6-29 (new task
button broken after reload). See PROBLEMS.md.
2026-04-17 21:01:25 +09:00
Dominic Cooney 903d1988a7 fix(S6-27): restore conversation when opening task from history
The gRPC handler for showTaskWithId was calling controller.initTask()
which starts a NEW SDK session instead of loading the existing task's
messages from disk. Changed to call controller.showTaskWithId(id)
which correctly: (1) looks up the history item, (2) tears down any
active session, (3) creates a task proxy with loaded messages,
(4) pushes messages through both state updates and partial message
stream, (5) posts state to the webview.

Verified with debug harness: send message → new task → click history
item → conversation restored with all messages.
2026-04-17 21:01:25 +09:00
Dominic Cooney eef62bdf68 Deleting tasks is reflected immediately in history. 2026-04-17 21:01:25 +09:00
Dominic Cooney ca2ac02b17 fix: S6-24 tool input preservation, S6-6 path mismatch, restore streaming state push
- S6-24: Preserve tool input from content_start for use at content_end
  (MessageTranslatorState stores streamingToolInput/streamingToolName)
- S6-6: Replace readUiMessages (legacy path) with getSavedClineMessages
  (uses HostProvider.globalStorageFsPath, matching saveClineMessages)
- Restore postStateToWebview() in handleSessionEvent (needed for streaming)
- Fix TS2352 cast in message-translator.ts (as unknown as Record)
- Add 4 new tests for tool input preservation through streaming lifecycle
- S6-26: Research SDK pending prompts/tool approval/ask_question system
- S6-27: Create focused task for history messages still not rendering
- Update PROBLEMS.md priority section
2026-04-17 21:01:25 +09:00
Dominic Cooney a866b21b91 fix: display inference messages in webview (SDK migration)
The partial message handler in ExtensionStateContext only updated
existing messages by matching timestamps — it never appended new ones.
In the classic extension, messages were first added via state updates,
then updated in-place by partial messages. In the SDK migration,
messages arrive via the partial message stream first, so they need
to be appended when no existing message matches.

Also adds debounced ClineMessage persistence in SdkController so
task history can load messages via readUiMessages().
2026-04-17 21:01:25 +09:00
Dominic Cooney 6f6abaf28d docs: update PROBLEMS.md with verification results and new issues
- S6-5: Regressed — inference works but view doesn't switch to chat
- S6-6: Failed verification, merged S6-15 into it
- S6-8: Marked as verified fixed (brown logo)
- S6-19: New — history deletion dialog confirms but doesn't delete
- S6-20: New — MCP tools panel is empty
- Added Priority & Next Steps section recommending S6-5 as top priority
2026-04-17 21:01:25 +09:00
Dominic Cooney a53b425500 fix: build system prompt for SDK sessions to enable inference
The SDK's DefaultSessionManager passes the systemPrompt from
CoreSessionConfig directly to the Agent — there is no fallback for
empty system prompts. Both the CLI and SDK's VSCode extension call
buildClineSystemPrompt() before passing config to the session manager.

Our buildSessionConfig() was setting systemPrompt: '' (empty string),
which caused the gateway to return empty responses with 0 tokens.

Changes:
- cline-session-factory.ts: Import buildWorkspaceMetadata from
  @clinebot/core and buildClineSystemPrompt from @clinebot/shared.
  Build the full Cline system prompt in buildSessionConfig() using
  workspace metadata, IDE name, mode, provider, and platform.
  Falls back to a minimal prompt on error.
- vscode-session-host.ts: Add logging around send() for debugging
  inference issues (input/output tokens, response text).
- SdkController.ts: Add logging for session events and agent turn
  completion to aid debugging.

Verified: Agent responds correctly with 2776 input tokens (system
prompt) and generates proper output ('Hello, world! 👋').
2026-04-17 21:01:25 +09:00
Dominic Cooney bbdc008e7c document new problems: S6-15 through S6-18
- S6-15: History items not clickable (blocker)
- S6-16: Sending message completes immediately with no output (blocker)
- S6-17: Cancel button enabled after task completes (minor)
- S6-18: Missing API key shows error instead of login prompt (blocker)
2026-04-17 21:01:25 +09:00
Dominic Cooney 8161e4b917 simplify oauth: read credentials from providers.json directly
- Rewrite restoreRefreshTokenAndRetrieveAuthInfo() to read from providers.json
  instead of injecting through StateManager secrets
- Add fetchUserInfoFromApi() to get user profile from Cline API at startup
- Delete VscodeOAuthTokenManager (~180 lines) - SDK default handles persistence
- Simplify resolveApiKey() for cline provider to read from providers.json
- Cache getProviderSettings() as singleton to avoid re-reading file
- Net reduction of 94 lines
2026-04-17 21:01:25 +09:00
Dominic Cooney f0f0231809 Add codebase search guidance for avoiding build output
Documents which directories contain minified/generated code that
produces noisy search results (out/, dist/, src/generated/, etc.),
how to skip them with search_files and grep, and how to search
minified files when necessary (grep -oP, source maps).
2026-04-17 21:01:25 +09:00
Dominic Cooney 0d7e9d9a9f sdk: wire VscodeSessionHost into SdkController for end-to-end inference
Replace ClineCore.create() with VscodeSessionHost.create() which
constructs DefaultSessionManager directly with VSCode-specific options:

1. VscodeSessionHost (new file):
   - Implements SessionManager interface, wrapping DefaultSessionManager
   - Injects source: 'vscode' on start() for telemetry tagging
   - Writes empty MCP settings to prevent SDK's default MCP loading

2. VscodeOAuthTokenManager:
   - Reads Cline OAuth tokens from secrets.json (cline:clineAccountId)
   - Refreshes tokens via SDK's refreshClineToken()
   - Returns null on re-auth failure instead of throwing
     OAuthReauthRequiredError (prevents 'Run clite auth' error)
   - Delegates non-cline OAuth (openai-codex, oca) to SDK's
     ProviderSettingsManager

3. VscodeRuntimeBuilder (from previous commit, now wired in):
   - Bridges classic McpHub to SDK tool system
   - Supports all MCP transports (stdio, SSE, streamableHttp)

4. SdkController changes:
   - initTask() uses VscodeSessionHost.create({ mcpHub })
   - ActiveSession.core -> ActiveSession.sessionManager
   - Removed createClineCore() and MCP filtering from session factory

All 99 SDK unit tests pass. No new TypeScript errors.
2026-04-17 21:01:25 +09:00
Dominic Cooney 8aa519494a sdk: fix webview state, chat history, and add VscodeRuntimeBuilder
Four fixes for making inference work in the VSCode UI:

1. Webview state connection (S6-13): Wire WebviewGrpcBridge to the
   controller's getStateToPostToWebview() so state updates include
   messages, currentTaskItem, and task history. Added setGetStateFn()
   method to the bridge and called it from SdkController constructor.

2. Chat history loading (S6-6): Fix history lookup in showTaskWithId()
   and reinitExistingTaskFromId() to check StateManager's taskHistory
   first (where updateTaskHistory writes), then fall back to the legacy
   file reader. Also clear active session before viewing history.

3. Message translation (S6-12): Add sdkToolToClineSayTool() mapping
   function that converts SDK tool names (read_files, editor, etc.) to
   classic ClineSayTool format that ChatRow.tsx expects. Add usage event
   handling for api_req_started messages. Update tests to match actual
   output format.

4. VscodeRuntimeBuilder (S6-14): New custom RuntimeBuilder that bridges
   the classic McpHub to the SDK's tool system. Delegates builtin tools
   to DefaultRuntimeBuilder but replaces MCP tools with ones loaded from
   the classic McpHub, supporting all transport types (stdio, SSE,
   streamableHttp). Not yet wired into session creation — needs
   VscodeSessionHost wrapper (see S6-9).

All 99 SDK unit tests pass.
2026-04-17 21:01:25 +09:00
Dominic Cooney 2b77fdac6c feat(sdk): fix inference pipeline — credential resolution, non-blocking start, MCP filtering
- Fix credential resolution: replace broken ProviderSettingsManager and
  buildApiHandlerSettings() paths with resolveApiKey()/resolveModelId()
  that read directly from StateManager.getApiConfiguration() (includes
  secrets). Handles all 30+ providers including cline OAuth token
  extraction (idToken from cline:clineAccountId JSON, workos: prefix).

- Fix non-blocking session start: initTask() now calls
  core.start({interactive:true}) WITHOUT a prompt (returns immediately),
  then fire-and-forgets core.send() for inference. Events stream
  in real-time via subscribe(). Same pattern for askResponse().

- Add MCP settings filtering: SDK's StdioMcpClient only supports stdio
  transport. ensureFilteredMcpSettings() writes a filtered copy of
  cline_mcp_settings.json (stdio-only) and sets CLINE_MCP_SETTINGS_PATH
  env var. Future: replace with custom RuntimeBuilder that provides a
  clientFactory delegating to classic McpHub for streamableHttp support.

- Fix debug harness gRPC message format for web.post_message.

- Update PROBLEMS.md: S6-5 and S6-11 marked 🟢 Verified Fixed.

Verified: Debug harness sends newTask → session starts, agent runs,
events stream to webview (iteration_start, usage, iteration_end, done).
2026-04-17 21:01:25 +09:00
Dominic Cooney afb67aca96 Add Claude analysis of the branch. 2026-04-17 21:01:25 +09:00
Dominic Cooney 385360f8c8 Fix: empty clineEnv in updateSettings flips to local environment
Protobuf defaults empty strings to ''. The check 'request.clineEnv !== undefined'
was true for empty strings, causing ClineEnv.setEnvironment('') which defaults
to 'production' but also triggers accountLogoutClicked(). This caused the user
to be logged out and the environment to appear to flip when changing models.

Fix: Also check 'request.clineEnv !== ""' before processing.
2026-04-17 21:01:25 +09:00
Dominic Cooney d9fb4c97ba Fix critical bugs: history item saving, VSCode API exposure, logging
1. initTask() now saves history item to StateManager immediately
   after session starts. Without this, currentTaskItem was undefined
   in getStateToPostToWebview(), so the webview never switched to
   chat view after sending a message.

2. updateTaskHistory() and deleteTaskFromState() are now real
   implementations using StateManager instead of stubs.

3. Exposed window.__clineVsCodeApi in webview for debug harness
   access (platform.config.ts).

4. Added detailed logging to initTask() for debugging:
   - Session config (provider, model, apiKey presence)
   - ClineCore creation
   - Session start
   - Error details

These fixes address the user-reported issues:
- Send button clears input but doesn't switch to chat view
- Chat history appears empty
2026-04-17 21:01:25 +09:00
Dominic Cooney 2c2b5a29e0 Update README with Step 7 & 8 progress 2026-04-17 21:01:25 +09:00
Dominic Cooney c2e353fc54 Step 8: Settings & Features — TaskProxy compatibility + togglePlanActMode
TaskProxy improvements for settings handler compatibility:
- api property is now settable (updateSettings replaces it on model switch)
- terminalManager returns a proper stub that safely no-ops
  (setDefaultTerminalProfile, setShellIntegrationTimeout, etc.)
- Added TaskProxyTerminalManager interface

SdkController improvements:
- togglePlanActMode() now properly implemented:
  - Saves mode to StateManager
  - Cancels active task if switching modes mid-task
  - Posts state update to webview
- toggleActModeForYoloMode() switches to act mode

These changes ensure the updateSettings gRPC handler works
without modification — it calls controller.task.api and
controller.task.terminalManager which are now properly stubbed.
2026-04-17 21:01:25 +09:00
Dominic Cooney 3ce23e18e7 Step 7: Wire classic McpHub into SdkController
Following the 'Thunk, Don't Replace' principle, wire the classic
McpHub into SdkController instead of building a custom SDK MCP
manager. The classic McpHub already supports all three transports
(stdio, SSE, streamableHTTP), file watching, and all gRPC handlers.

Changes:
- SdkController.mcpHub: type changed from 'any' to 'McpHub'
- Constructor initializes McpHub with same args as classic Controller
- Existing gRPC handlers (subscribeToMcpServers, restartMcpServer,
  deleteMcpServer, toggleMcpServer, etc.) work without modification
- SDK's InMemoryMcpManager will replace it in Step 10 (Cleanup)

Also includes:
- Fix S6-5: buildSessionConfig() falls back to classic StateManager
  when SDK ProviderSettingsManager has no provider configured
- Fix S6-6: showTaskWithId() loads messages from disk via
  readUiMessages() and adds them to TaskProxy's messageStateHandler
- Updated PROBLEMS.md and README.md with Step 7 progress
2026-04-17 21:01:25 +09:00
Dominic Cooney 932a311df6 Fix S6-5 & S6-6: provider config fallback + history message loading
S6-5: buildSessionConfig() now falls back to classic StateManager
- Try SDK ProviderSettingsManager first (providers.json)
- If no provider/apiKey found, fall back to StateManager.buildApiHandlerSettings()
- This correctly resolves provider/model/apiKey for the current mode (plan/act)
- Critical because providers.json may not exist yet for existing users

S6-6: showTaskWithId() now loads messages from disk
- Call readUiMessages(taskId) to load ui_messages.json
- Add messages to TaskProxy's messageStateHandler via addMessages()
- This populates the message state so getStateToPostToWebview() returns them

Also: remove unused Settings import from cline-session-factory.ts,
add StateManager import for the fallback path.
2026-04-17 21:01:25 +09:00
Dominic Cooney ead771644d Step 6: Auth & Account Flows — SDK-backed auth and account services
- Add src/sdk/auth-service.ts: SDK-backed AuthService replacing classic AuthService
  - loginClineOAuth(), loginOcaOAuth(), loginOpenAICodex() via SDK functions
  - Token persistence to secrets.json with workos: prefix
  - Cross-window auth sync via secrets change listener
  - Streaming subscriptions with immediate initial state push
- Add src/sdk/account-service.ts: SDK-backed ClineAccountService
  - Authenticated API requests using SDK-backed AuthService
  - Credit fetching, org switching, payment history
- Wire gRPC handlers to SDK-backed auth:
  - accountLoginClicked, accountLogoutClicked, subscribeToAuthStatusUpdate
  - openAiCodexSignIn, openAiCodexSignOut
- Update SdkController to initialize auth/account services
- Update extension.ts secrets listener to use new auth-service
- 20 unit tests in auth-service.test.ts
- Update sdk-migration/README.md and PROBLEMS.md for Step 6

Status: Implementation complete, awaiting E2E verification
Blockers: S6-5 (inference not starting), S6-6 (history items not loading)
2026-04-17 21:01:24 +09:00
Dominic Cooney b76d8707ba Step 5: gRPC thunking layer — TaskProxy + WebviewGrpcBridge
- src/sdk/task-proxy.ts: TaskProxy provides classic Task-compatible
  interface that delegates to SDK session methods. MessageStateHandler
  extends EventEmitter for CLI compatibility (on/off pattern).
  TaskProxyState mirrors classic TaskState subset.

- src/sdk/webview-grpc-bridge.ts: Bridges SDK session events to
  webview gRPC streams. Translates ClineMessages to proto format
  and pushes through sendPartialMessageEvent/sendStateUpdate.

- src/sdk/SdkController.ts: Wired TaskProxy + WebviewGrpcBridge
  into session lifecycle. Events flow: SDK → message translator →
  gRPC bridge → webview streams. Reuses getStateToPostToWebview()
  for state building.

- No 'as' casts in production code — type narrowing used instead.
  Stubs throw errors instead of returning undefined as unknown as T.

- 114 unit tests pass across 6 test files.
- 0 new TypeScript errors (3 pre-existing in unrelated files).
- Extension loads, sidebar renders, newTask routes correctly.
- initTask fails at runtime because ClineCore.create() needs SDK
  config (Step 6+).
2026-04-17 21:01:24 +09:00
Dominic Cooney 77c0c12bcb Step 4: Session lifecycle — SDK adapter layer
Implement session lifecycle for the SDK migration:

- src/sdk/cline-session-factory.ts: Build CoreSessionConfig from
  legacy state via ProviderSettingsManager, create ClineCore instances,
  build StartSessionInput/resume inputs, HistoryItem CRUD helpers

- src/sdk/message-translator.ts: Translate all SDK CoreSessionEvent
  types to ClineMessage[] for webview consumption. Handles chunk,
  agent_event (content_start/update/end, done, error, notice, usage),
  ended, hook, status events. Streaming state tracking for partial
  message dedup.

- src/sdk/SdkController.ts: Session lifecycle methods (initTask,
  askResponse, cancelTask, clearTask, showTaskWithId,
  reinitExistingTaskFromId), SDK event subscription/translation
  pipeline, session event listener system.

- Tests: 91 unit tests across 4 files (27 message-translator,
  37 legacy-state-reader, 15 cline-session-factory, 12
  provider-migration). TypeScript compiles with 0 errors.

- Updated sdk-migration/README.md: Step 4 marked completed,
  improved debug harness overlay dismiss instructions.

- Updated sdk-migration/PROBLEMS.md: Step 4 verified, 3 minor
  known issues documented (S4-1, S4-2, S4-3).
2026-04-17 21:01:24 +09:00
Dominic Cooney 57524e9c2f Step 3: Provider Migration — SDK-backed credential migration
Implements src/sdk/provider-migration.ts with:
- migrateProviders() using SDK's ProviderSettingsManager auto-migration
- getProviderSettingsManager() for accessing provider settings
- Supports all 30+ providers (Anthropic, OpenAI, OpenRouter, Bedrock, Ollama, Cline, etc.)
- Never overwrites existing entries (idempotent)
- Tags migrated entries with tokenSource: 'migration'
- 12 unit tests passing, 0 TypeScript errors
2026-04-17 21:01:24 +09:00
Dominic Cooney 44a008fe6e Step 2: Legacy State Reader — read all on-disk state from SDK adapter layer
Implements src/sdk/legacy-state-reader.ts with:
- readGlobalState/readGlobalStateKey for globalState.json
- readSecrets/readSecretKey for secrets.json
- readTaskHistory for state/taskHistory.json
- readApiConversationHistory, readUiMessages, readContextHistory, readTaskMetadata for per-task data
- readMcpSettings for settings/cline_mcp_settings.json
- listTaskIds for task directory listing
- readAllLegacyState composite reader
- All reads are non-throwing (missing/corrupt files return typed defaults)
- 37 unit tests passing, 0 TypeScript errors
2026-04-17 21:01:24 +09:00
Dominic Cooney 20cca8f526 Step 1: Foundation & Cutover - SDK adapter layer
- Add @clinebot/core, @clinebot/llms, @clinebot/shared, @clinebot/agents
  as dependencies via file: protocol (linked to ../sdk-wip)
- Create src/sdk/ directory with SdkController stub and barrel export
- Replace src/core/controller/index.ts with re-export from SDK adapter
  (classic Controller accessible via origin/main)
- Extract getStateToPostToWebview() to standalone function for reuse
- Add vitest.config.sdk.ts for SDK adapter tests
- Fix implicit any types in handler modules
- Extension compiles and builds successfully (tsc + esbuild pass)
- Single entry point: no CLINE_SDK flag, SDK adapter is the only codepath

Replaces classic src/core/controller/index.ts (see origin/main)
2026-04-17 21:01:24 +09:00
Dominic Cooney df8d054cbb sdk-migration-v3: single entry point, delete-and-document principle
Key changes from feedback:
- Replace 'don't delete what you haven't replaced' with 'delete and document'
  - Delete classic code immediately when replaced by SDK equivalent
  - Add 'Replaces classic src/core/... (see origin/main)' comments
  - Use kb_search/git to reference origin/main for classic implementation
- Single entry point: no CLINE_SDK env variable, no dual codepaths
  - Step 1 now modifies src/extension.ts directly
  - Rationale: dual entry points caused constant confusion in attempt 2
- Updated ARCHITECTURE.md with key architectural decisions
- Updated .clinerules/sdk-migration.md with new rules
2026-04-17 21:01:24 +09:00
Dominic Cooney 03df40209c sdk-migration-v3: seed the third migration attempt
- Port forward debug harness (server.ts, README.md, .clinerules)
- Port forward ws.d.ts type declaration
- Create sdk-migration/ doc structure:
  - README.md: entry point, 10-step plan, operational procedure
  - ARCHITECTURE.md: features, design decisions, SDK capabilities
  - SDK-REFERENCE/OAUTH.md: SDK OAuth reference with pitfalls
  - SDK-REFERENCE/MCP.md: SDK MCP reference with gap analysis
  - PROBLEMS.md: issue tracker with verification requirements
- Add .clinerules/sdk-migration.md for agent guidance

Key changes from attempt 2:
- gRPC thunking instead of typed message replacement
- Verification gates before each step
- Never delete what you haven't replaced
- Structured problem tracking with evidence requirements
- Concise, purpose-specific docs with fan-out structure
2026-04-17 21:01:24 +09:00
56 changed files with 12762 additions and 1175 deletions
+51
View File
@@ -0,0 +1,51 @@
# Debug Harness
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
## Quick start
```bash
# Build extension first if needed (protos + esbuild):
npm run protos && IS_DEV=true node esbuild.mjs
# Launch (skip-build if already built):
npx tsx src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
```
## Key commands
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
- **`launch`** / **`shutdown`** — lifecycle
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}`**use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
- **`ui.open_sidebar`** — open the Cline sidebar
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
- **`ext.call_stack`** — inspect when paused
- **`web.evaluate`** `{expression}` — eval in webview
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
- **`ui.locator`** `{role?, testId?, text?, action?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
- **`ui.command_palette`** `{command}` — run VSCode command
## Caveats
- **⚠️ Dismiss "Introducing Cline Kanban" overlay FIRST**: On fresh launches a full-screen promo overlay blocks the sidebar. **Dismiss it immediately after `ui.open_sidebar`**, before any other interaction or screenshot. Most reliable method:
```bash
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelector(\".sr-only\")?.parentElement?.click()"}}'
```
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on the path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
- **macOS only** for now (Playwright Electron launch behavior).
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
See `src/dev/debug-harness/README.md` for full API reference.
+43
View File
@@ -18,6 +18,49 @@ This file is the secret sauce for working effectively in this codebase. It captu
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
## Searching the Codebase — Avoiding Build Output
Several directories contain build output or generated code that produces
noisy or unusable results with `search_files` / `grep`:
| Directory | What it is | Why it's a problem |
|-----------|-----------|-------------------|
| `out/` | esbuild bundle output | Mirrors `src/` structure as minified JS — every search gets duplicate hits on single-line files |
| `dist/` | Packaged extension | Entire extension bundled into one minified `extension.js` (~1 long line) |
| `dist-standalone/` | Standalone build output | Same minification issue |
| `src/generated/` | Generated protobuf code | Auto-generated from `proto/`; not the source of truth |
| `src/shared/proto/` | Generated proto type defs | Auto-generated from `proto/`; not the source of truth |
| `node_modules/` | Dependencies | Huge, not project source |
### How to skip build output
**`search_files`** — Point at `src/` (not the project root) and use `file_pattern`:
```
search_files(path="src/core", regex="myFunction", file_pattern="*.ts")
```
The `file_pattern` parameter is the most effective filter — e.g. `"*.ts"`,
`"*.tsx"`, `"*.proto"`.
**`grep` directly** — Exclude build dirs and restrict to source extensions:
```bash
grep -rn "myFunction" src/ --include="*.ts" --exclude-dir={out,dist,node_modules,generated}
```
### When you must search minified files
Sometimes you need to verify what got bundled (e.g., checking if a change
made it into the build). Minified files are typically one long line, so
normal `grep` shows the entire file as context. Use these approaches:
- **`grep -oP`** to extract just the match with limited surrounding context:
```bash
grep -oP '.{0,40}myFunction.{0,40}' dist/extension.js
```
- **`read_file`** on files in `out/src/` — these have source maps and are
more readable than `dist/extension.js` (which is the fully bundled output).
- **Source maps** — `out/src/*.js.map` and `dist/extension.js.map` can be
used to trace minified output back to original source locations.
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
+29
View File
@@ -0,0 +1,29 @@
# SDK Migration
When working on the SDK migration (branch `sdk-migration-v3`), start by
reading `sdk-migration/README.md` in full. It contains the step-by-step
plan, core principles, and operational procedure.
Key documents:
- `sdk-migration/README.md` — Entry point, plan, steps
- `sdk-migration/ARCHITECTURE.md` — Design decisions, features, SDK capabilities
- `sdk-migration/SDK-REFERENCE/OAUTH.md` — SDK OAuth reference
- `sdk-migration/SDK-REFERENCE/MCP.md` — SDK MCP reference
- `sdk-migration/PROBLEMS.md` — Issue tracker with verification status
- `src/dev/debug-harness/README.md` — Debug harness API
## Critical Rules
1. **Always use `kb_search(name="sdk", query="...")` before implementing**
SDK features. Don't guess at APIs.
2. **Never mark a problem 🟢 without evidence.** Write the test first.
3. **Delete and document.** When replacing a classic module, delete it
immediately and add `// Replaces classic src/core/... (see origin/main)`.
Use `kb_search(name="cline", commit="origin/main")` or
`git show origin/main:path` to reference the classic implementation.
4. **Single entry point.** No `CLINE_SDK` env variable. There is one
codepath — the SDK adapter.
5. **Use `{appBaseUrl}`**, never hardcode `app.cline.bot`.
6. **Avoid `as` casts.** Use explicit conversion functions with tests.
7. **Dismiss the Kanban overlay** before any debug harness interaction.
8. **Use command palette** to navigate tabs in the debug harness.
+116
View File
@@ -20,6 +20,10 @@
"@azure/identity": "^4.13.0",
"@bufbuild/protobuf": "^2.2.5",
"@cerebras/cerebras_cloud_sdk": "^1.35.0",
"@clinebot/agents": "file:../sdk-wip/packages/agents",
"@clinebot/core": "file:../sdk-wip/packages/core",
"@clinebot/llms": "file:../sdk-wip/packages/llms",
"@clinebot/shared": "file:../sdk-wip/packages/shared",
"@google-cloud/vertexai": "^1.9.3",
"@google/genai": "^1.30.0",
"@grpc/grpc-js": "^1.9.15",
@@ -160,6 +164,102 @@
"vscode": "^1.84.0"
}
},
"../sdk-wip/packages/agents": {
"name": "@clinebot/agents",
"version": "0.0.34",
"license": "Apache-2.0",
"dependencies": {
"@clinebot/llms": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {
"@clinebot/shared": "workspace:*"
},
"engines": {
"node": ">=22"
}
},
"../sdk-wip/packages/core": {
"name": "@clinebot/core",
"version": "0.0.34",
"dependencies": {
"@clinebot/agents": "workspace:*",
"@clinebot/llms": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"jiti": "^1.21.7",
"nanoid": "^5.1.7",
"node-machine-id": "^1.1.12",
"simple-git": "^3.32.3",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@clinebot/rpc": "workspace:*",
"@clinebot/shared": "workspace:*"
},
"engines": {
"node": ">=22"
}
},
"../sdk-wip/packages/llms": {
"name": "@clinebot/llms",
"version": "0.0.34",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.89",
"@ai-sdk/anthropic": "^3.0.68",
"@ai-sdk/google": "^3.0.60",
"@ai-sdk/google-vertex": "^4.0.100",
"@ai-sdk/mistral": "^3.0.28",
"@ai-sdk/openai": "^3.0.52",
"@ai-sdk/openai-compatible": "^2.0.38",
"@aws-sdk/credential-providers": "^3.922.0",
"@clinebot/shared": "workspace:*",
"@langfuse/otel": "^4.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@streamparser/json": "^0.0.21",
"ai": "^6.0.144",
"ai-sdk-provider-claude-code": "^3.4.3",
"ai-sdk-provider-codex-cli": "^1.1.0",
"ai-sdk-provider-opencode-sdk": "^3.0.1",
"dify-ai-provider": "^1.1.0",
"nanoid": "^5.1.7",
"zod": "^4.3.6"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.0.0"
},
"peerDependenciesMeta": {
"@aws-sdk/client-bedrock-runtime": {
"optional": true
}
}
},
"../sdk-wip/packages/shared": {
"name": "@clinebot/shared",
"version": "0.0.34",
"dependencies": {
"jsonrepair": "^3.13.2",
"zod": "^4.3.6",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=22"
}
},
"cli": {
"name": "cline",
"version": "2.15.0",
@@ -2184,6 +2284,22 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/@clinebot/agents": {
"resolved": "../sdk-wip/packages/agents",
"link": true
},
"node_modules/@clinebot/core": {
"resolved": "../sdk-wip/packages/core",
"link": true
},
"node_modules/@clinebot/llms": {
"resolved": "../sdk-wip/packages/llms",
"link": true
},
"node_modules/@clinebot/shared": {
"resolved": "../sdk-wip/packages/shared",
"link": true
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
+4
View File
@@ -506,6 +506,10 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@clinebot/core": "file:../sdk-wip/packages/core",
"@clinebot/llms": "file:../sdk-wip/packages/llms",
"@clinebot/shared": "file:../sdk-wip/packages/shared",
"@clinebot/agents": "file:../sdk-wip/packages/agents",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
+265
View File
@@ -0,0 +1,265 @@
# SDK Migration — Architecture & Design
Evergreen reference for the Cline SDK migration project.
This document describes what we're building and why.
For the step-by-step plan, see [README.md](README.md).
## Product Background
There's a VSCode extension in `src/`. A large part of its UI is a
React-based webview in `webview-ui/`. There's a JetBrains plugin
that packages the core and communicates via protobufs. There's a
CLI that uses the SDK separately.
The Cline SDK (`@clinebot/core`, `@clinebot/llms`,
`@clinebot/agents`, `@clinebot/shared`) provides session management,
provider handling, tool execution, and MCP integration. Our goal is
to replace the classic core with the SDK while keeping the webview
mostly intact.
## Architecture
### Current (Classic)
```
VSCode Extension
WebviewProvider → Controller → Task → API providers (30+)
→ McpHub
Webview (React) ← gRPC/postMessage → Extension Host
proto/cline/*.proto defines message format
```
### Target (SDK-Backed)
```
VSCode Extension
WebviewProvider → SDK Adapter Layer → @clinebot/core
→ Custom MCP Manager
Webview (React) ← gRPC/postMessage → gRPC Thunk → SDK Adapter
(same proto messages — webview unchanged)
```
### Key Architectural Decision: gRPC Thunking
The webview communicates with the extension host via gRPC-over-postMessage.
We will **not** change this in the migration. Instead, we implement a
thunking layer that:
1. Receives gRPC-shaped requests from the webview
2. Translates them to SDK calls
3. Translates SDK responses back to gRPC shape
4. Pushes streaming updates (state, auth, partial messages) as
gRPC streaming responses
This means:
- The webview code is **largely untouched**
- Proto files stay until the final cleanup step
- Each SDK feature is wired by implementing its gRPC handler
### Key Architectural Decision: Single Entry Point
There is one extension entry point (`src/extension.ts`), modified to
use the SDK adapter. No `CLINE_SDK` environment variable, no dual
codepaths. The classic implementation is always accessible via
`origin/main` and `kb_search`.
### Key Architectural Decision: Delete and Document
When replacing a classic module with its SDK equivalent, we delete
the classic code immediately and add a comment in the replacement:
```
// Replaces classic src/core/task/ (see origin/main)
```
This eliminates confusion about what code is active. The classic
code is always recoverable from git.
### Future Architecture (Post-Migration)
```
VSCode Extension
SDK Adapter Layer → @clinebot/core
Webview (React) ← typed JSON messages → SDK Adapter
(gRPC removed; simpler message protocol)
JetBrains Plugin
Kotlin Plugin ← JSON-RPC/stdio → SDK Sidecar (Node.js)
JCEF Webview ← postMessage → SDK Sidecar
(shares SDK adapter layer with VSCode)
```
## Features
### Features to Remove
- **Browser automation** (Playwright) — replaced by MCP browser tools
- **IDE terminal integration** — replaced by background terminal
- **Shadow git checkpoints** — too slow; will be replaced later
- **Memory bank / structured context** — removed
- **Focus chain / task tracking** — removed
- **Deep planning / `/deep-planning`** — plan/act mode replaces it
- **Workflows** — skills (SKILL.md) replace them
- **`/reportbug`** — removed
### Core Features (Must Work)
- File operations: read, write, search, replace, list files
- Background terminal execution
- Multi-provider AI models (30+ providers)
- Auto-approve & YOLO mode
- Auto-compaction
- Subagents
- Web search and web fetch
- Worktrees
- Workspaces
- Jupyter Notebooks
- Cline Rules
- Skills
- Hooks
- .clineignore
- MCP (stdio + SSE + streamableHTTP)
### Core Workflows (Must Work)
- Task lifecycle: create, resume, history, cost tracking
- Plan & Act mode with optional separate model configs
- File context (@-mentions)
- Slash commands: /newtask, /smol, /newrule
### Model Configuration
- 30+ providers with seamless switching
- **Critical**: Preserve existing credentials — never log users out
- Support local models (Ollama, LM Studio)
- Cline provider with unified auth, billing, org switching
- VSCode LM API provider (Copilot) if possible
### P1 Features (Can Follow Up)
- Checkpoints (kanban-style git refs, not shadow git)
- Diffing between checkpoints
- Restore files/task to checkpoint
- MCP Marketplace
### P2 Features (Later)
- Task favorites and grouping
- File drag-and-drop context
- `/explain-changes` slash command
## Design Principles
### Naming: "Sdk..." Considered Harmful
Don't name types `SdkFoo` or folders `sdk`. The SDK backing is an
implementation detail. Use simple noun phrases. During migration,
`SdkFoo` as a temporary alias is OK, but rename before completion.
### Proto Deprecation
Protos for webview messages will eventually be replaced by shared
TypeScript interfaces. But **not during this migration** — we keep
the gRPC thunking layer and remove protos only in the final cleanup.
Protos for persisted state (if any) can stay indefinitely.
### Data Formats & Settings
- **Must** pick up existing on-disk state
- Never log users out of their providers
- CLI, VSCode, and JetBrains share state on disk — continue that
- Design migrations with breadcrumbs and downgrade robustness
- Protect against corrupt JSON writes (atomic write-then-rename)
### Webview UI
- Reuse the existing webview — do NOT build from scratch
- Familiar, not worse, preferably better
- Simplify state management where the SDK enables it
- Fix known defects (n² state updates, wrong keybindings) when
the opportunity arises
## What the SDK Provides
These capabilities exist in the SDK and do not need to be rebuilt:
1. **Legacy provider settings migration**
`migrateLegacyProviderSettings()` reads `globalState.json` +
`secrets.json`, writes to `providers.json`
2. **30+ provider handlers** — Anthropic, OpenAI, Gemini, Bedrock,
Vertex, DeepSeek, Ollama, LM Studio, etc.
3. **Custom handler registry**`registerHandler(id, factory)` for
VSCode LM API and other host-specific providers
4. **MCP management**`InMemoryMcpManager` with stdio, SSE,
streamableHttp transports (but needs custom factory for non-stdio)
5. **Tool framework** — 8 built-in tools, preset system, per-tool
policies, model-aware routing
6. **Session lifecycle**`ClineCore.create()``host.start()` /
`host.send()` / `host.abort()` / `host.subscribe()`
7. **Telemetry**`TelemetryService` with pluggable adapters
8. **Rules & Skills** — Discovery from `.clinerules/`,
`~/Documents/Cline/Rules`, etc.
9. **Hooks**`HookEngine` with lifecycle events
10. **Subagents/Teams**`AgentTeamsRuntime`, spawn tools
11. **System prompt generation**`getClineDefaultSystemPrompt()`
12. **OAuth token management**`RuntimeOAuthTokenManager` for
automatic refresh
13. **Storage isolation**`CLINE_DIR`, `CLINE_DATA_DIR` env vars
## SDK Gaps (Known)
These features need custom implementation in the adapter layer:
1. **MCP settings file watcher** — SDK doesn't watch for changes
2. **MCP manager exposure** — Runtime builder encapsulates the
manager; clients can't call lifecycle methods on running sessions
3. **SSE/StreamableHTTP client** — Default factory only creates
stdio clients; we need a custom factory
4. **RPC endpoints for MCP** — No MCP management in the RPC layer
5. **OAuth callback handling** — SDK provides the server and URL,
but the client must open the browser and persist tokens
See `SDK-REFERENCE/MCP.md` and `SDK-REFERENCE/OAUTH.md` for details.
## JetBrains IPC Design (Future)
The target is JSON-RPC over stdio between the Kotlin plugin and a
Node.js sidecar. See the original ARCHITECTURE.md for the full
design. This is **not in scope** for the current migration —
VSCode comes first.
## Test Strategy
### Unit Tests
- **SDK adapter tests**: Vitest (no vscode mock needed)
- **Extension unit tests**: Mocha with vscode mock (existing)
- **Webview tests**: Vitest + React Testing Library (existing)
### Integration Tests
- **Debug harness**: Playwright-driven VSCode with CDP access
- **QA scripts**: Curl-based test sequences for core flows
### SDK Storage Isolation
```typescript
import { setClineDir, setHomeDir } from "@clinebot/shared/storage"
const tempHome = mkdtempSync(join(tmpdir(), "test-home-"))
process.env.HOME = tempHome
process.env.CLINE_DIR = join(tempHome, ".cline")
process.env.CLINE_DATA_DIR = join(tempHome, ".cline", "data")
setHomeDir(tempHome)
setClineDir(process.env.CLINE_DIR)
```
## Manual QA Risk Areas
1. **Provider credentials** — Verify API keys survive upgrade/downgrade
2. **Cline provider OAuth/SSO** — Sign-in, sign-out, refresh, org switch
3. **Chat streaming** — Missing/duplicated messages, performance
4. **Tool approval** — Auto-approve, YOLO, per-tool permissions
5. **Plan/Act mode** — Toggle, separate models, persistence
6. **Task history** — Old tasks appear, new tasks save, resume works
7. **MCP servers** — Configs picked up, tools work
8. **Settings UI** — All toggles persist
9. **Webview performance** — Long conversations don't lag
+621
View File
@@ -0,0 +1,621 @@
# SDK Migration — Known Issues & Verification Tracker
This file tracks problems found during the migration. Each problem
has a status and verification evidence. Problems are never marked
🟢 without evidence.
## Status Legend
- 🔴 **Blocker** — prevents core functionality
- 🟡 **Minor** — cosmetic or UX annoyance
- 🔵 **Awaiting Verification** — fix attempted, not yet verified
- 🟢 **Verified Fixed** — fix confirmed with evidence
## Known Issues From Previous Attempt
These issues were present in the second migration attempt. They
are listed here as a reference for what to watch out for. They
do not necessarily apply to this attempt's codebase, but the
underlying patterns that caused them are relevant.
### Auth & Account (Highest Risk Area)
| ID | Description | Status |
|----|-------------|--------|
| A1 | Inference works when appearing logged out | Carried pattern |
| A2 | Inference NOT working when appearing logged in | Carried pattern |
| A3 | Login button does nothing or opens wrong URL | Carried pattern |
| A4 | Logout button does nothing | Carried pattern |
| A5 | Profile/credits/history not displayed when logged in | Carried pattern |
| A6 | Error messages instead of login buttons when actually logged out | Carried pattern |
| A7 | Hardcoded `app.cline.bot` instead of `{appBaseUrl}` | Carried pattern |
| A8 | `workos:` prefix inconsistency on account IDs | Carried pattern |
| A9 | Org switching doesn't update inference profile | Carried pattern |
| A10 | Low credit balance persists after switching orgs | Carried pattern |
### gRPC Thunking
| ID | Description | Status |
|----|-------------|--------|
| G1 | Stubbed handlers return `{data:{}}` causing webview crashes | Carried pattern |
| G2 | Proto field name mismatches (e.g., `taskId` vs `id`) | Carried pattern |
| G3 | Streaming subscriptions race condition | Carried pattern |
| G4 | "SDK mode" vs "classic mode" confusion | Addressed by design |
### Feature Removal
| ID | Description | Status |
|----|-------------|--------|
| F1 | Empty `if (request.type === "workflow") {}` blocks | Carried pattern |
| F2 | Features marked "legacy" instead of actually removed | Carried pattern |
| F3 | Workflows tab still in Cline Rules modal | Carried pattern |
| F4 | Terminal settings show IDE terminal options | Carried pattern |
### UI / Webview
| ID | Description | Status |
|----|-------------|--------|
| U1 | Copy button obscured by code blocks | Carried pattern |
| U2 | Token usage bar shows 0/0 | Carried pattern |
| U3 | Input text not cleared immediately on send | Carried pattern |
| U4 | Task history items not clickable | Carried pattern |
| U5 | MCP server management buttons are no-ops | Carried pattern |
| U6 | MCP Marketplace never loads | Carried pattern |
| U7 | Tool output rectangles appear blank | Carried pattern |
## New Issues
### Step 1: Foundation & Cutover — Completed
- **Status**: 🟢 Verified Fixed
- **Description**: SDK adapter layer created as single entry point. Extension compiles and builds.
- **Verification**: `npx tsc --noEmit` returns 0 errors. `node esbuild.mjs` produces `dist/extension.js`.
- **Evidence**: Commit `3dec59fe9` on `sdk-migration-v3` branch.
### S1-1: SdkController stubs log warnings at runtime
- **Status**: 🟡 Minor
- **Description**: All unimplemented Controller methods log `[SdkController] STUB: <name> not yet implemented`. This is expected — functionality is added in Steps 4-8.
- **Root cause**: By design — stub pattern for incremental migration.
- **Fix**: Implement each method in its corresponding step.
### S1-2: Services not initialized (mcpHub, authService, etc.)
- **Status**: 🟢 Verified Fixed
- **Description**: The SdkController now initializes `authService`, `ocaAuthService`, `accountService` (Step 6), and `mcpHub` (Step 7). All core services are initialized.
- **Root cause**: N/A — fixed incrementally in Steps 6 and 7.
- **Fix**: Auth and account services wired in Step 6. MCP hub wired in Step 7 using classic McpHub (will be replaced by SDK's InMemoryMcpManager in Step 10).
### S1-3: Extension loads but sidebar shows errors
- **Status**: 🟢 Verified Fixed
- **Description**: Extension loads, sidebar renders correctly with full UI (chat input, model selector, announcements, auto-approve settings). No error elements in the webview.
- **Verification**: Debug harness launched with `--auto-launch`, sidebar opened, `document.querySelectorAll("[data-testid=error], .error, .codicon-error").length` returns 0. Sending a message via `ui.send_message` returns `{"sent": true, "method": "newTask"}` without crash. Task doesn't start (expected — `initTask` is a stub).
- **Evidence**: Debug harness session on 2026-04-13, commit `3dec59fe9`.
### Step 2: Legacy State Reader — Completed
- **Status**: 🟢 Verified Fixed
- **Description**: `src/sdk/legacy-state-reader.ts` reads all existing on-disk state from the Cline data directory. Supports globalState.json, secrets.json, taskHistory.json, per-task data (api_conversation_history, ui_messages, context_history, task_metadata), MCP settings, and task directory listing.
- **Verification**: 37 unit tests pass (`npx vitest run --config vitest.config.sdk.ts`). TypeScript compiles with 0 errors (`npx tsc --noEmit`). All reads are non-throwing — missing/corrupt files return typed defaults.
- **Evidence**: All tests pass on 2026-04-13.
### Step 3: Provider Migration — Completed
- **Status**: 🟢 Verified Fixed
- **Description**: `src/sdk/provider-migration.ts` uses the SDK's `ProviderSettingsManager` to auto-migrate legacy provider credentials from `globalState.json` + `secrets.json` to the SDK's `providers.json` format. Supports all 30+ providers. Never overwrites existing entries. Tags migrated entries with `tokenSource: "migration"`. Idempotent.
- **Verification**: 12 unit tests pass covering Anthropic, OpenAI, OpenRouter, Bedrock, Ollama, Cline providers, no-overwrite guarantee, idempotency, and missing state handling. TypeScript compiles with 0 errors.
- **Evidence**: All tests pass on 2026-04-13.
### Step 4: Session Lifecycle — Completed
- **Status**: 🟢 Verified Fixed
- **Description**: Session lifecycle implemented in `src/sdk/cline-session-factory.ts`, `src/sdk/message-translator.ts`, and `src/sdk/SdkController.ts`. The SdkController now has working `initTask()`, `askResponse()`, `cancelTask()`, `clearTask()`, `showTaskWithId()`, and `reinitExistingTaskFromId()` methods that create SDK sessions via `ClineCore`, subscribe to events, translate them to `ClineMessage[]`, and emit to listeners. The message translator handles all SDK event types: `chunk`, `agent_event` (content_start/update/end, done, error, notice, iteration_start/end, usage), `ended`, `hook`, and `status`. Session factory builds `CoreSessionConfig` from legacy state via `ProviderSettingsManager` and creates `HistoryItem` records.
- **Verification**: 91 unit tests pass across 4 test files (27 message-translator, 37 legacy-state-reader, 15 cline-session-factory, 12 provider-migration). TypeScript compiles with 0 errors in `src/sdk/`. Tests cover: streaming state tracking, all event type translations, full streaming flows (text→tool→text), history item CRUD, session input building, and provider config resolution.
- **Evidence**: All tests pass on 2026-04-13. `npx tsc --noEmit` returns 0 errors in `src/sdk/`.
### Step 5: gRPC Thunking Layer — Completed
- **Status**: 🟢 Verified Fixed
- **Description**: gRPC thunking layer implemented in `src/sdk/task-proxy.ts` and `src/sdk/webview-grpc-bridge.ts`. The `TaskProxy` provides a classic Task-compatible interface that delegates to SDK session methods, allowing existing gRPC handlers to work without modification. The `WebviewGrpcBridge` translates SDK session events to proto ClineMessages and pushes them through the existing `subscribeToPartialMessage` and `subscribeToState` gRPC streams. The `MessageStateHandler` extends `EventEmitter` for CLI compatibility (on/off pattern). The SdkController wires everything together: session events → message translation → gRPC bridge → webview streams.
- **Verification**: 114 unit tests pass across 6 test files (16 task-proxy, 7 webview-grpc-bridge, 27 message-translator, 37 legacy-state-reader, 15 cline-session-factory, 12 provider-migration). TypeScript compiles with 0 new errors (3 pre-existing errors in unrelated files). Tests cover: TaskProxy delegation, MessageStateHandler event emission, WebviewGrpcBridge message/state pushing, error handling.
- **Evidence**: All tests pass on 2026-04-13. `npx tsc --noEmit` returns only 3 pre-existing errors (searchFiles.ts, commit-message-generator.ts).
### S4-1: Session lifecycle not yet wired to gRPC handlers
- **Status**: 🟢 Verified Fixed
- **Description**: The SdkController's session lifecycle methods are now wired to the gRPC handler layer via the TaskProxy. The webview's `newTask` and `askResponse` messages flow through: gRPC handler → TaskProxy → SdkController → SDK session. Session events flow back: SDK → message translator → WebviewGrpcBridge → gRPC streams → webview.
- **Root cause**: N/A — fixed in Step 5.
- **Fix**: TaskProxy delegates `handleWebviewAskResponse()` and `abortTask()` to SdkController callbacks. WebviewGrpcBridge pushes translated messages through `sendPartialMessageEvent()` and `sendStateUpdate()`.
### S4-2: Task resumption uses new session instead of SDK resume API
- **Status**: 🟢 Verified Fixed
- **Description**: Resumption now works with preserved context. When a user opens a historical task and sends a follow-up, `SdkController.askResponse()` resumes by creating a session with the existing task ID and loading prior conversation as `initialMessages`.
- **Root cause**: The old flow had no active SDK session for history-only tasks, so follow-up prompts had no session context.
- **Fix applied**: Implemented `resumeSessionFromTask()` in `src/sdk/SdkController.ts` (commit `34afde1c5`). The flow reads persisted SDK messages (`readMessages(taskId)`) with fallback to classic `api_conversation_history`, starts a session with `config.sessionId = taskId`, posts the user follow-up immediately to chat, then sends the prompt to the resumed session.
- **Verification**: Manual verification via resumed history task + follow-up message.
- **Evidence**: Commit `34afde1c5` (“resume session working”).
### S4-3: Workspace root not available from ClineExtensionContext
- **Status**: 🟡 Minor
- **Description**: `ClineExtensionContext` doesn't have a `workspaceRoot` property. The SdkController falls back to `process.cwd()` for the session's working directory. In VSCode, the workspace root is available from the VSCode extension context but not from the shared `ClineExtensionContext` type.
- **Root cause**: The shared context type was designed for CLI/ACP use and doesn't include VSCode-specific workspace info.
- **Fix**: Add workspace root resolution in the host-specific initialization (VSCode host, CLI host) and pass it to the SdkController.
### Step 6: Auth & Account Flows — Completed
- **Status**: 🟢 Verified Fixed
- **Description**: SDK-backed auth and account services implemented. `src/sdk/auth-service.ts` replaces classic `src/services/auth/AuthService.ts`, using `@clinebot/core` OAuth functions (`loginClineOAuth`, `loginOcaOAuth`, `loginOpenAICodex`, `refreshClineToken`) for login flows while maintaining compatibility with the existing gRPC handler interface. `src/sdk/account-service.ts` replaces classic `src/services/account/ClineAccountService.ts`, making authenticated API requests using the SDK-backed AuthService for token management. The SdkController now initializes `authService`, `ocaAuthService`, and `accountService` in its constructor and restores auth state from secrets on startup. gRPC handlers (`accountLoginClicked`, `accountLogoutClicked`, `subscribeToAuthStatusUpdate`, `openAiCodexSignIn`, `openAiCodexSignOut`) now import from `@/sdk/auth-service` instead of the classic `@/services/auth/AuthService`. The `extension.ts` secrets listener also imports from the new location.
- **Key design decisions**:
- Auth info persisted in `secrets.json` under `cline:clineAccountId` (same key as classic)
- Tokens stored with `workos:` prefix for API compatibility
- Token refresh uses SDK's `refreshClineToken()` with automatic retry and error recovery
- Cross-window auth sync via secrets change listener preserved
- Codex credentials stored via SDK's `ProviderSettingsManager`
- `handleAuthCallback()` supports URI-handler-based OAuth flow (code exchange)
- Streaming subscriptions push initial auth state immediately (prevents race condition)
- **Verification**: 20 unit tests pass in `src/sdk/auth-service.test.ts`. TypeScript compiles with 0 new errors. Tests cover: singleton pattern, auth state management, organization lookup, token persistence (read/write/clear), logout flow, workos: prefix handling, streaming subscriptions, and auth restoration on startup.
- **Evidence**: All tests pass on 2026-04-14. `npx tsc --noEmit` returns only pre-existing errors (none in `src/sdk/`).
### S6-1: Auth login flow not yet verified end-to-end
- **Status**: 🟢 Verified Fixed
- **Description**: The SDK-backed `loginClineOAuth()` flow has not been tested with a real browser OAuth flow. The classic flow used Firebase custom token exchange; the SDK flow uses a local callback server. Need to verify: (1) browser opens correctly, (2) callback server receives the code, (3) tokens are exchanged and persisted, (4) webview shows authenticated state.
- **Root cause**: Requires debug harness + real Cline account.
- **Fix**: Test with debug harness using `ui.send_message` to trigger login flow.
- **Verification**: Debug harness `ui.screenshot` after login should show user avatar/credits.
### S6-2: OCA and Codex OAuth flows not yet verified
- **Status**: 🔵 Awaiting Verification
- **Description**: `ocaLogin()` and `openAiCodexLogin()` delegate to SDK functions but haven't been tested end-to-end. The Codex flow stores credentials via `ProviderSettingsManager` instead of the classic `openAiCodexOAuthManager`.
- **Root cause**: Requires real OAuth providers.
- **Fix**: Manual testing with debug harness.
### S6-3: MCP OAuth callback stubbed
- **Status**: 🟢 Verified Fixed
- **Description**: MCP OAuth callback is now implemented.
- **Root cause**: Previously delegated to stub path.
- **Fix applied**: `SdkController.handleMcpOAuthCallback()` now calls `mcpHub.completeOAuth(serverHash, code, state)` and posts updated state to the webview, with error logging on failure.
- **Verification**: Manual OAuth callback test with remote Notion MCP server.
- **Evidence**: Commit `a8ac26e36` (“fix mcp oauth callback”).
### S6-5: Sending messages creates history entry but doesn't switch to inference view
- **Status**: 🟢 Verified Fixed
- **Description**: Inference itself works (the SDK agent runs, produces output, and the session completes with tokens). However, the webview does NOT switch from the welcome/history view to the chat/inference view when a message is sent. A new entry appears in the task history sidebar, but the user stays on the welcome page and never sees the agent's output.
- **Root cause**: The view transition depends on `clineMessages` having at least one message (the "task" message) in the state update. The webview's `ChatView.tsx` shows the chat view when `messages.at(0)` is truthy. Previously, the task message was only sent via the partial message stream but NOT included in the state's `clineMessages` (see S6-22). When the state update arrived with empty `clineMessages`, the webview saw no messages and stayed on the welcome view.
- **Fix applied**: Same as S6-22 — the task message is now added to `messageStateHandler` before emitting, so the state update includes it in `clineMessages`. The webview receives `clineMessages` with the task message and switches to the chat view.
- **Verification**: Send a message, verify the webview switches to the chat view showing the agent's streaming output.
- **Evidence**: Manual verification on 2026-04-16 — new chats display and do inference.
### S6-6: Clicking historical chat items does nothing (includes S6-15)
- **Status**: 🔵 Awaiting Verification (three fixes applied)
- **Description**: Clicking on a task in the history view now opens the chat view and stays there (no more flash-back to welcome). Previously, the chat view showed only "Thinking" with no messages displayed. The task's messages were loaded from disk but not rendering in the webview.
- **Root cause (flash-back fixed)**: `showTaskWithId()` was rewritten to avoid `clearTask()` race condition. The view now stays on the chat view.
- **Root cause (messages missing — fixed)**: Two issues:
1. The messages loaded from disk were added to `messageStateHandler` and included in the state update's `clineMessages`, but the webview relies on the partial message stream for rendering individual messages. The state update alone wasn't sufficient — messages also need to be pushed through the partial message stream (`subscribeToPartialMessage`).
2. **Path mismatch**: `showTaskWithId()` was using `readUiMessages()` from `legacy-state-reader.ts` which reads from `~/.cline/data/tasks/<id>/ui_messages.json`. But `saveClineMessages()` (from `disk.ts`) writes to `HostProvider.globalStorageFsPath/tasks/<id>/ui_messages.json` — a different path (e.g., VSCode's extension storage). The messages were being saved to one location and read from another, so `readUiMessages()` always returned an empty array.
- **Fix applied**:
1. **(flash-back)**: Rewrote `showTaskWithId()` in `SdkController.ts` to avoid calling `clearTask()`. Instead: (1) unsubscribe from events FIRST, (2) clear `activeSession` reference, (3) fire-and-forget session stop/dispose, (4) create new task proxy with loaded messages BEFORE state push, (5) only then call `postStateToWebview()`.
2. **(messages — partial stream)**: In `showTaskWithId()`, after loading messages from disk and adding them to `messageStateHandler`, also push each message through the partial message stream via `pushMessageToWebview()`. The webview receives messages from two sources: state updates (bulk) and partial messages (individual). Pushing through both ensures the webview has messages regardless of timing. The webview deduplicates by timestamp, so duplicate pushes are harmless.
3. **(messages — path mismatch)**: Replaced `readUiMessages()` (from `legacy-state-reader.ts`) with `getSavedClineMessages()` (from `@core/storage/disk`) in `showTaskWithId()`. Both `saveClineMessages` and `getSavedClineMessages` use `HostProvider.globalStorageFsPath` as the base path, so they read/write from the same location. Removed the unused `readUiMessages` import.
- **Verification**: Click a history item, verify the chat view loads with the task's messages visible.
### S6-7: Credits/payment history don't load immediately on startup
- **Status**: 🟡 Minor
- **Description**: After login, the available tokens and payment history don't appear immediately. They show up after clicking refresh. This is a timing issue — the first `getStateToPostToWebview()` call may happen before the auth token is fully restored.
- **Root cause**: Race condition between auth restoration and initial state push.
- **Fix**: Ensure `restoreRefreshTokenAndRetrieveAuthInfo()` completes before the first state push, or trigger a re-fetch after auth restoration completes.
### S6-4: Provider-specific OAuth callbacks (OpenRouter, Requesty, Hicap) stubbed
- **Status**: 🟢 Verified Fixed
- **Description**: Provider OAuth callbacks are now implemented for OpenRouter, Requesty, and Hicap.
- **Root cause**: Previously low-priority stubs.
- **Fix applied**:
- `SdkController` now routes all three callbacks to `authService` and posts state updates.
- `auth-service.ts` implements:
- `handleOpenRouterCallback(code)` via OpenRouter code→API key exchange (`/api/v1/auth/keys`), then persists config.
- `handleRequestyCallback(code)` and `handleHicapCallback(code)` by persisting provider API keys and switching plan/act providers.
- Added shared helper `setProviderApiKey()` for consistency.
- **Verification**: Manual OpenRouter login flow tested end-to-end (provider selected, “get OpenRouter API key”, prompt sent successfully).
- **Evidence**: Commits `d68e83981` and `69d500f87`.
### S6-8: Debug harness loads extension in "local" environment (brown logo)
- **Status**: 🟢 Verified Fixed
- **Description**: When run via the debug harness, the extension appears in "local" environment mode (brown Cline logo) instead of "production" mode (white-on-black logo). The production VSCode launch configuration works correctly.
- **Root cause**: `src/dev/debug-harness/server.ts:370` hardcodes `CLINE_ENVIRONMENT: "local"` in the environment variables passed to the extension host.
- **Fix**: Changed to `"production"` or made configurable.
- **Evidence**: Manual verification on 2026-04-16.
### S6-9: DefaultSessionManager has multiple CLI-oriented assumptions
- **Status**: 🔵 Awaiting Verification (VscodeSessionHost wired into SdkController)
- **Description**: `DefaultSessionManager` was designed primarily for the SDK's CLI (`clite`) and has several assumptions that don't fit the VSCode extension context. These are all addressable through the constructor options or by wrapping/catching, but must be accounted for:
**a) Hardcoded "clite" in OAuth error messages** (`default-session-manager.ts:1377`):
`syncOAuthCredentials()` throws `Run "clite auth ${error.providerId}" and retry.` when OAuth re-auth is needed. Meaningless in VSCode.
**Mitigation**: Provide a custom `oauthTokenManager` that handles re-auth through the extension's login flow, or catch this error in SdkController and show a login button.
**b) Session source defaults to `SessionSource.CLI`** (line 199):
Every session is tagged as `"cli"` in telemetry and session manifests. VSCode sessions should be tagged differently.
**Mitigation**: Pass `source: SessionSource.VSCODE` (or equivalent) in `StartSessionInput`. Check if `SessionSource` has a VSCode variant; if not, use a custom string.
**c) OAuth token manager uses `ProviderSettingsManager` for token storage** (lines 185-190):
The default `RuntimeOAuthTokenManager` reads/writes tokens via `ProviderSettingsManager` (`providers.json`). The VSCode extension stores OAuth tokens in `secrets.json` under `cline:clineAccountId`. The default manager won't find them.
**Mitigation**: Provide a custom `oauthTokenManager` that reads from the extension's `secrets.json` / `StateManager`.
**d) `providerSettingsManager` defaults to reading `providers.json`** (lines 183-184):
`buildResolvedProviderConfig()` (line 268) uses this to resolve provider config including `knownModels` and `reasoningSettings`. If the extension's credentials aren't in `providers.json`, this resolution may produce incomplete config.
**Mitigation**: Provide a custom `providerSettingsManager` or ensure `providers.json` is kept in sync.
**e) `start()` and `send()` block until the agent turn completes** (lines 411-420, 437-475):
Both methods are blocking — they return only after the agent finishes its turn. Events stream in real-time via `subscribe()`, but the calling code is blocked. This is fine for CLI but problematic for gRPC handlers that need to return immediately.
**Mitigation**: Fire-and-forget the `start()`/`send()` calls (don't await in the gRPC handler), or run them in a background task. The `sdk-migration-fri` branch awaits them but pushes UI state before calling.
**f) Tools are built once per session — no mid-session tool list changes** (line 296-318):
`runtimeBuilder.build()` is called once at session start. The resulting `runtime.tools` array plus `config.extraTools` are merged and passed to the agent. There is no mechanism to add/remove tools from the array mid-session.
**Important distinction — tool policies vs tool list:**
- **Tool policies** (`toolPolicies: Record<string, ToolPolicy>`) control whether each tool is `enabled` and `autoApprove`d. The CLI mutates the policies object in-place mid-session and the agent sees changes on the next tool call. The VSCode auto-approve settings dialog maps to **policy changes**, which ARE supported natively.
- **Tool list** (the actual `Tool[]` array) is static after `build()`. Adding/removing MCP servers mid-session requires changing this array, which is NOT supported.
**Mitigation for auto-approve toggles**: Use `toolPolicies` mutation or `requestToolApproval` callback — both work mid-session.
**Mitigation for MCP tool list changes**: The SDK supports `initialMessages` on `start()`, which pre-loads conversation history into a new session. The Tauri desktop app (`apps/code/host/runtime-bridge.ts`) already uses this pattern for checkpoint restoration. When MCP servers change mid-session: (1) stop the current session, (2) read its messages via `readMessages(sessionId)`, (3) start a new session with `initialMessages` set to those messages + the updated MCP tool list. The agent continues seamlessly. This is simpler and more robust than dynamic tool wrappers.
**g) No mechanism for IDE-specific tool executors at the `DefaultSessionManager` level**:
The `defaultToolExecutors` option (line 310) allows overriding how builtin tools execute (e.g., `bash`, `editor`). This IS the extensibility point for IDE-specific behavior like using VSCode's integrated terminal. However, the executor interface is defined by the SDK and may not cover all VSCode-specific needs (e.g., diff view, browser session).
**Mitigation**: Investigate the `ToolExecutors` interface to see what's overridable. For tools not covered, use `extraTools` to provide custom implementations.
- **Root cause**: The SDK was designed as a host-agnostic runtime. The `DefaultSessionManager` provides sensible defaults for CLI use, but VSCode integration requires overriding several of these defaults. All fields are `private readonly` — the class cannot be subclassed. `ClineCore.create()` always creates a `DefaultSessionManager` internally via `createSessionHost()` — there's no way to inject a custom `SessionHost`.
- **Architecture decision — Wrapper vs Fork vs Direct Use:**
**Option A: Direct use of `ClineCore.create()`** — Cannot customize `source`, cannot intercept OAuth errors. ❌ Insufficient.
**Option B: Fork `DefaultSessionManager`** — Write a `VscodeSessionManager` (1516 lines to maintain). Full control but high maintenance burden. Reserve as fallback.
**Option C (Recommended): Wrapper around `DefaultSessionManager`** — Construct `DefaultSessionManager` directly (it's exported), pass all custom options, then wrap it in a thin `VscodeSessionHost` that implements `SessionManager`:
- Intercepts `start()` to inject `source: "vscode"`
- Provides custom `oauthTokenManager` that reads from `secrets.json`/`StateManager` and triggers VSCode login UI on re-auth (preventing the "clite" error path entirely — `syncOAuthCredentials` only throws the "clite" message when `OAuthReauthRequiredError` is caught, so if our custom manager handles re-auth differently, that code path is never reached)
- Provides custom `runtimeBuilder` for MCP (see S6-10)
- Provides `requestToolApproval` for VSCode approval UI
- Provides `defaultToolExecutors` for IDE-specific behavior
- Catches and translates any remaining errors from `send()`/`start()` into VSCode-appropriate signals
The wrapper is ~50-100 lines. If we hit walls where internal behavior can't be intercepted at the boundary, escalate to Option B.
- **Fix needed**: Create `src/sdk/vscode-session-host.ts` with the following custom components:
**1. `VscodeSessionHost` (wrapper, ~50-100 lines)**
- Implements `SessionManager` interface (13 methods: `start`, `send`, `abort`, `stop`, `dispose`, `get`, `list`, `delete`, `readMessages`, `readTranscript`, `readHooks`, `subscribe`, `getAccumulatedUsage`)
- Delegates all methods to an inner `DefaultSessionManager`
- Intercepts `start()` to inject `source: "vscode"` (or check `SessionSource` enum for a VSCode variant)
- Catches errors from `start()`/`send()` and translates OAuth re-auth errors into VSCode-friendly signals (e.g., emit an event that triggers the login UI)
**2. `VscodeOAuthTokenManager` (custom `oauthTokenManager`, ~50 lines)**
- Implements `RuntimeOAuthTokenManager` interface (check `packages/core/src/session/` for the interface)
- `resolveProviderApiKey({ providerId, forceRefresh })`: reads OAuth tokens from `secrets.json` via `StateManager.get().getSecretKey("cline:clineAccountId")`, extracts `idToken`, adds `workos:` prefix
- On re-auth failure: instead of throwing `OAuthReauthRequiredError` (which triggers the "clite" message), emit a signal/event that the SdkController can use to show the VSCode login UI
- This prevents the "clite" error path in `syncOAuthCredentials` from ever being reached
**3. `VscodeRuntimeBuilder` (custom `runtimeBuilder`, ~100 lines)**
- Implements `RuntimeBuilder` interface (`build(config): { tools: Tool[], shutdown: () => void, ... }`)
- For builtin tools: delegate to `DefaultRuntimeBuilder`
- For MCP tools: read currently-connected servers from `McpHub`, convert to SDK `Tool[]` format
- See S6-10 for full MCP integration details
**4. `requestToolApproval` callback (~30 lines)**
- Receives `ToolApprovalRequest` with `toolName`, `input`, `policy`
- If `policy.autoApprove` is true: return `{ approved: true }` immediately
- Otherwise: emit an event to the webview showing the approval dialog, await user response
- Return `{ approved: boolean, reason?: string }`
**5. Wire into `SdkController`:**
- Replace `ClineCore.create()` with direct `DefaultSessionManager` construction + `VscodeSessionHost` wrapper
- Pass `VscodeOAuthTokenManager`, `VscodeRuntimeBuilder`, `requestToolApproval`, `defaultToolExecutors`
- Use `VscodeSessionHost.subscribe()` for event streaming to the webview
**Reference**: `DefaultSessionManager` constructor options at `packages/core/src/session/default-session-manager.ts:138-151`. `SessionManager` interface at `packages/core/src/session/session-manager.ts:57-73`. `RuntimeOAuthTokenManager` in `packages/core/src/session/`. `RuntimeBuilder` interface in `packages/core/src/runtime/`.
### S6-10: DefaultRuntimeBuilder loads MCP tools once — no file watching
- **Status**: 🟢 Verified Fixed
- **Description**: The SDK's `DefaultRuntimeBuilder.loadConfiguredMcpTools()` reads MCP settings from `CLINE_MCP_SETTINGS_PATH` (or default path) **once** at session start. It creates an `InMemoryMcpManager`, connects all servers, and returns tools. There is **no file watching** — changes to the MCP settings file after session start are not detected.
- **Root cause**: The SDK's MCP integration was designed for CLI/batch use where sessions are short-lived. The VSCode extension's `McpHub` watches the settings file, supports dynamic connect/disconnect, provides real-time server status to the webview, and supports the MCP Marketplace.
- **Impact**: Users cannot add/remove/restart MCP servers without restarting the extension. MCP server status in the webview will be stale. MCP Marketplace installs won't take effect until next session.
- **Fix needed**: Two-layer approach:
1. **McpHub stays as the lifecycle manager**: Keep the classic `McpHub` for file watching, dynamic connect/disconnect, server status UI, and MCP Marketplace. It manages the MCP settings file and server connections independently of the SDK session.
2. **Custom RuntimeBuilder bridges McpHub → SDK tools**: At session start, a custom `RuntimeBuilder` reads the currently-connected MCP servers from `McpHub` and converts them to SDK `Tool[]` format. For builtin tools (editor, bash, etc.), delegate to `DefaultRuntimeBuilder`.
3. **Session restart on MCP tool list changes**: When `McpHub` detects that MCP servers have been added or removed (file watcher fires), and there's an active session: (a) stop the current session, (b) read its messages via `readMessages(sessionId)`, (c) start a new session with `initialMessages` set to those messages. The new session's `RuntimeBuilder.build()` will pick up the updated MCP tool list from `McpHub`. The Tauri desktop app (`apps/code/host/runtime-bridge.ts`) already uses this `initialMessages` pattern for checkpoint restoration.
**History deduplication caveat**: A session restart creates a new session ID. The old session's persisted data stays on disk, which would create a duplicate entry in the task history list. The Tauri desktop app avoids this via its "threads" abstraction — the UI tracks threads, not raw sessions, and updates the thread's session reference. For the VSCode extension, we need to either: (a) delete the old session's history entry when restarting, (b) mark it as "superseded" and filter it from the history view, or (c) reuse the same task ID / history entry and just swap the underlying session. Option (c) is cleanest — the `SdkController` already maintains a `currentTaskItem` that maps to the history view; on restart, keep the same task item and just update the internal session reference.
4. **No session restart needed for MCP tool policy changes**: If the user just toggles auto-approve for an MCP tool, that's a `toolPolicies` mutation — no session restart required.
- **Reference**: Classic extension's `McpHub` in `src/services/mcp/McpHub.ts`; SDK's `InMemoryMcpManager` in `packages/core/src/extensions/mcp/`; Tauri desktop's session restart pattern in `apps/code/host/runtime-bridge.ts`
### S6-12: Webview shows raw JSON instead of rendered messages
- **Status**: 🟢 Verified Fixed
- **Description**: When the SDK streams events to the webview, the ChatRow.tsx component shows raw JSON instead of properly rendered messages (text, tool calls, etc.). The message translator was producing ClineMessages with the wrong format for tool calls — using `tool_name`/`tool_input`/`tool_output` keys instead of the `text` field with XML-like `<tool_name>...</tool_name>` format that ChatRow.tsx expects.
- **Root cause**: The message translator's `translateToolCall()` and `translateToolResult()` methods were creating ClineMessages with custom fields (`tool_name`, `tool_input`, `tool_output`) that the webview's ChatRow.tsx doesn't understand. The classic Task class formats tool calls as XML-like text in the `text` field (e.g., `<read_file>\n<path>file.ts</path>\n</read_file>`), and ChatRow.tsx parses this format to render tool-specific UI.
- **Fix applied**: Rewrote `translateToolCall()` and `translateToolResult()` in `src/sdk/message-translator.ts` to format tool calls as XML-like text in the `text` field, matching the classic Task's format. Added `formatToolCallText()` and `formatToolResultText()` helper functions. Updated `translateTextChunk()` to handle partial text streaming. Updated `translateAgentEvent()` to properly track tool call state (pending tool name, accumulating input, partial text).
- **Verification**: Send a message that triggers tool use, verify ChatRow renders the tool call with proper formatting (file path, command, etc.) instead of raw JSON.
- **Evidence**: Commits `bc3590534` and `26614a007` expanded SDK tool→webview mapping and added regression tests (`message-translator.test.ts`, `messageUtils.test.ts`) including multi-file `read_files` rendering and post-tool assistant text visibility.
### S6-13: Webview state not populated with messages and task history
- **Status**: 🔵 Awaiting Verification
- **Description**: The webview's `ExtensionStateContext` wasn't receiving messages, current task item, or task history. The `subscribeToState` stream was pushing state updates without task data because the `WebviewGrpcBridge.pushStateUpdate()` method was building state without the controller's task reference.
- **Root cause**: The `WebviewGrpcBridge` was importing `getStateToPostToWebview()` directly and calling it with `task: undefined`, which meant the state never included messages or the current task item. The bridge didn't have access to the controller's `getStateToPostToWebview()` method which knows about the active task.
- **Fix applied**:
1. Added `setGetStateFn()` method to `WebviewGrpcBridge` that accepts the controller's `getStateToPostToWebview` bound method.
2. Updated `pushStateUpdate()` to use `getStateFn` when available (which includes task data), falling back to the minimal state builder.
3. Wired `grpcBridge.setGetStateFn(() => this.getStateToPostToWebview())` in `SdkController` constructor.
- **Verification**: Send a message, verify the webview shows messages in the chat view and the task appears in history.
### S6-14: VscodeRuntimeBuilder for MCP tool bridging
- **Status**: 🟢 Verified Fixed
- **Description**: The SDK's `DefaultRuntimeBuilder.loadConfiguredMcpTools()` only supports stdio transport. SSE and streamableHttp MCP servers are filtered out, causing "Unsupported MCP transport" errors. The classic `McpHub` already supports all three transports.
- **Root cause**: The SDK's `InMemoryMcpManager` with `createDefaultMcpServerClientFactory()` only creates stdio clients. The VSCode extension's `McpHub` has its own connection management that supports stdio, SSE, and streamableHttp.
- **Fix applied**: Created `src/sdk/vscode-runtime-builder.ts` with:
1. `McpHubToolProvider` — adapter that makes the classic McpHub look like an SDK `McpToolProvider` (implements `listTools()` and `callTool()` by delegating to McpHub).
2. `VscodeRuntimeBuilder` — custom `RuntimeBuilder` that delegates builtin tool creation to `DefaultRuntimeBuilder` but replaces MCP tools with ones loaded from the classic `McpHub`. This gives the SDK agent access to all MCP servers regardless of transport type.
3. Tool name transform matches SDK's default (`serverName__toolName` format).
- **Wiring**: The `VscodeRuntimeBuilder` is now wired into session creation via `VscodeSessionHost.create()`, which passes it as the `runtimeBuilder` option to `DefaultSessionManager`. The `VscodeSessionHost` also writes an empty MCP settings file and points `CLINE_MCP_SETTINGS_PATH` to it, so the `DefaultRuntimeBuilder`'s internal `loadConfiguredMcpTools()` loads no MCP tools — the `VscodeRuntimeBuilder` replaces them with tools from the classic `McpHub`.
- **Verification**: Start a session with MCP servers configured (including SSE/streamableHttp), verify the agent can use MCP tools from all transport types.
### S6-11: Credential caching from classic extension may not work
- **Status**: 🟢 Verified Fixed
- **Description**: Cached credentials from the classic extension (`globalState.json` + `secrets.json`) are now correctly reused. The `buildSessionConfig()` function reads from `StateManager.getApiConfiguration()` (which includes secrets) and uses `resolveApiKey()` / `resolveModelId()` functions that handle all 30+ providers including the "cline" provider's OAuth token extraction.
- **Root cause (fixed)**: Same as S6-5 — replaced broken `ProviderSettingsManager` and `buildApiHandlerSettings()` paths with direct `ApiConfiguration` reading.
- **Fix applied**: `src/sdk/cline-session-factory.ts``resolveApiKey()`, `resolveModelId()`, `resolveBaseUrl()` functions that read from `StateManager.getApiConfiguration()`.
- **Verification**: Debug harness session shows inference working with `z-ai/glm-5.1` provider using cached credentials. No re-login required.
- **Evidence**: Same as S6-5 — debug harness session on 2026-04-14.
### S6-15: History items not clickable (welcome page and history view)
- **Status**: 🔴 Blocker — **Merged into S6-6**
- **Description**: Same issue as S6-6. Clicking history items from the welcome page does nothing. Clicking history items from the history view navigates back to the welcome page instead of loading the task.
- **Note**: This issue is tracked under S6-6. Likely shares a common root cause with S6-5 (view transition logic).
### S6-16: Sending a message completes immediately with no output
- **Status**: 🟢 Verified Fixed
- **Description**: When the user types and submits a message, the task immediately shows as "completed" with no tokens, no size, and no output. The webview console shows `handleSendMessage - Sending message: <text>` followed by four `ended "got subscribed state"` messages. No inference occurs.
- **Root cause**: Two issues:
1. **Inference was actually working** — the SDK agent ran, produced output, and completed with tokens. But the output was invisible because of issue #2.
2. **Partial message handler dropped new messages** — The webview's `ExtensionStateContext.tsx` partial message handler only updated existing messages by matching timestamps (`findLastIndex` by `ts`). If no existing message matched, the message was silently dropped (`return prevState`). In the classic extension, messages were first added via state updates, then updated in-place by partial messages. In the SDK migration, messages arrive via the partial message stream *before* any state update, so they were all dropped.
- **Fix**: In `webview-ui/src/context/ExtensionStateContext.tsx`, when a partial message arrives with a new timestamp (no match), append it to the `clineMessages` array instead of returning `prevState` unchanged. Also added debounced ClineMessage persistence in `SdkController.ts` so task history can load messages via `readUiMessages()`.
- **Verification**: Debug harness: sent "Say hello", Playwright locator found 3 elements containing "Hello" (user message + AI response). SDK returned: `"Hello! 👋 How can I help you today?"` with `inputTokens: 2776, outputTokens: 36, totalCost: 0.01478`.
- **Evidence**: Commit `32f1fa84e` on `sdk-migration-v3`.
### S6-17: Cancel button enabled after task "completes" but does nothing
- **Status**: 🟡 Minor
- **Description**: Despite the task showing as "completed", the cancel button remains enabled. Clicking it disables the button but has no visible effect. Sending a follow-up message after cancellation just logs `handleSendMessage` again with no inference.
- **Root cause**: Likely related to S6-16 — the task state isn't being properly set to "completed" in the webview, so the cancel button's enabled/disabled state is wrong. The follow-up message issue is the same root cause as S6-16.
- **Fix**: Fix S6-16 first. Then verify the task completion state properly disables the cancel button and enables the follow-up input.
### S6-18: Missing API key shows error instead of login prompt
- **Status**: 🔴 Blocker
- **Description**: When not logged in and attempting inference with the "cline" provider, instead of showing a login prompt, the user sees a red error message: `Missing API key for provider "cline". Set apiKey explicitly or one of: CLINE_API_KEY.` followed by "Thinking..." that spins forever.
- **Root cause**: The `resolveApiKey()` function in `cline-session-factory.ts` reads the access token from `providers.json`. When the user is not logged in, there's no token, and the SDK throws a generic "missing API key" error. The classic extension would detect the missing Cline credentials and show a login button instead. The error handling in `SdkController.initTask()` doesn't distinguish between "missing credentials for cline provider" (should show login UI) and other API key errors.
- **Fix**: In `SdkController.initTask()` or the session error handler, detect when the error is about missing Cline credentials specifically and emit a signal to the webview to show the login UI instead of a generic error. Alternatively, check for Cline credentials before starting the session and redirect to login if missing.
- **Verification**: Log out, attempt to send a message with "cline" provider selected, verify a login prompt appears instead of the error.
### S6-19: History deletion dialog confirms but doesn't delete
- **Status**: 🟢 Verified Fixed
- **Description**: When clicking the delete button on a history item, a confirmation dialog appears. After confirming, the item is deleted from state/disk AND the UI updates immediately — both the history list and the recents list on the welcome page reflect the deletion.
- **Root cause**: The `deleteTaskWithId` handler in `src/core/controller/task/deleteTasksWithIds.ts` called `controller.getTaskWithId(id)` before `deleteTaskFromState(id)`. When the task's `apiConversationHistory` file didn't exist on disk (common for new/short tasks), `getTaskWithId()` threw `"Task not found"`, which was caught and re-thrown. The `postStateToWebview()` call at the end of the function was outside the try/catch block and was never reached. The state was updated (because `getTaskWithId` called `deleteTaskFromState` internally before throwing), but the webview was never notified.
- **Fix applied**: Restructured `deleteTaskWithId()` to: (1) call `deleteTaskFromState(id)` first (always succeeds, updates in-memory cache immediately), (2) clean up task files on disk as best-effort (wrapped in try/catch), (3) always call `postStateToWebview()` at the end. Removed the `getTaskWithId()` call entirely — it's not needed for deletion since the task directory path can be constructed directly from the ID. Also simplified file cleanup to use `fs.rm(taskDirPath, { recursive: true, force: true })` instead of deleting individual files.
- **Verification**: Debug harness test on 2026-04-16: Created 2 tasks ("Say hello world", "Say goodbye world"). Deleted "Say goodbye world" via the history view delete button. History list immediately showed only "Say hello world" (1 delete button, size 682 B down from 1.3 kB). Navigated to welcome page — recents list showed only "Say hello world". Disk state confirmed: only 1 task in `taskHistory.json`, only 1 task directory remaining.
- **Evidence**: Debug harness session on 2026-04-16.
### S6-20: MCP tools panel is empty / MCP tools not available to agent
- **Status**: 🟢 Verified Fixed
- **Description**: Two related issues: (1) The MCP tools panel in the sidebar shows no tools, even when MCP servers are configured. (2) The SDK's DefaultSessionBuilder does not support dynamic MCP tools — tools are loaded once at session build time, so adding/removing MCP servers mid-session had no effect.
- **Root cause**: The VscodeRuntimeBuilder already bridges McpHub → SDK tools at session start, but there was no mechanism to reload tools when the McpHub's server list changed after session creation.
- **Fix**: Implemented a tool-list-change detection and session restart mechanism:
- `McpHub.ts`: Added `computeToolFingerprint()` to detect actual tool list changes (vs. mere status updates), `setToolListChangeCallback()`/`clearToolListChangeCallback()` for subscribers, and `checkToolListChanged()` called from `notifyWebviewOfServerChanges()`.
- `SdkController.ts`: Added `handleMcpToolListChanged()` which restarts the session immediately when idle, or defers via `mcpToolRestartPending` flag until the current turn completes (`checkDeferredMcpToolRestart()` called from `handleSessionEvent()` on turn completion). `restartSessionForMcpTools()` creates a new VscodeSessionHost with fresh tools, preserves conversation messages, and emits info messages to the chat.
- `task-proxy.ts`: Made `taskId` settable so the session restart can update the proxy's session ID without recreating it (preserving accumulated messages).
- **Tests**: 16 unit tests in `src/services/mcp/__tests__/McpHub.toolListChange.test.ts` covering fingerprinting, callback firing, edge cases.
- **Verification**: Start a task, then add/remove an MCP server in `cline_mcp_settings.json`. The chat should show "MCP tools changed — reloading tools for this session..." and "MCP tools reloaded successfully." The agent should then be able to use the new tools.
### S6-21: Incremental messages are repeated/duplicated in chat output
- **Status**: 🟢 Verified Fixed
- **Description**: After the S6-16 fix (appending new partial messages), the AI response text was repeated multiple times in the chat. Additionally, during streaming, the text appeared in a "flip book" style — fragments flashed and replaced each other rather than smoothly appending.
- **Root cause**: The message translator was using `event.text` (the delta/chunk) for streaming text messages. The SDK emits MULTIPLE `content_start` events during streaming, each with `text` (delta) and `accumulated` (full text so far). Using the delta caused each update to replace the previous content with just the new chunk, creating a "flip book" effect.
- **Fix applied**: Changed `message-translator.ts` to use `event.accumulated ?? event.text` for streaming text content_start events. This gives smooth streaming — the webview updates the message in-place with the growing accumulated text.
- **Note on state push**: An earlier fix attempt removed `postStateToWebview()` from `handleSessionEvent()` to prevent double state updates. This was reverted because the webview needs the full `clineMessages` array in state for proper rendering — without it, streaming appeared completely broken (the webview sat on "Thinking" and only showed the completed response at the end). The `postStateToWebview()` call is now restored. The `MessageStateHandler.addMessages()` deduplicates by timestamp, so the state update and partial message stream don't cause duplication.
- **Verification**: 34 unit tests pass in `message-translator.test.ts` including 3 new tests for accumulated text streaming behavior.
- **Evidence**: `npx vitest run --config vitest.config.sdk.ts src/sdk/message-translator.test.ts` — 34/34 pass.
### S6-22: User input message displays as "{}" instead of message text
- **Status**: 🔵 Awaiting Verification
- **Description**: The task header box at the top of the chat shows `{}` instead of the actual user message text (e.g., "Say hello"). The message is sent correctly (inference works), but the display of the user's input in the chat header is wrong.
- **Root cause**: The initial "task" message was emitted via `emitSessionEvents()` in `SdkController.initTask()`, which sent it to listeners (including the gRPC bridge for partial message streaming) but did NOT add it to the `messageStateHandler`. When `getStateToPostToWebview()` built the state, `clineMessages` from the handler was empty (missing the task message). The state update then arrived at the webview and replaced the partial-message-sourced `clineMessages` (which had the task message) with the empty state `clineMessages`, losing the user's input text. The webview then showed `{}` because `task.text` was undefined.
- **Fix applied**: In `SdkController.initTask()`, the task message is now added to `this.task.messageStateHandler.addMessages([taskMessage])` BEFORE emitting to listeners. This ensures `getStateToPostToWebview()` includes the task message in `clineMessages`, so the state update preserves it.
- **Verification**: Send a message, verify the task header shows the actual message text.
### S6-23: Opening a message from history returns to welcome screen
- **Status**: 🔵 Awaiting Verification — **Same fix as S6-6**
- **Description**: Clicking a task in the history list briefly flashes the chat view, then returns to the welcome screen. Opening a recent conversation from the welcome screen also shows a brief flash and returns to the welcome screen. The `showTaskWithId()` method loads messages from disk but the view transition doesn't stick.
- **Root cause**: Same as S6-6 — `showTaskWithId()` called `clearTask()` which set `this.task = undefined` and triggered async session teardown that raced with the new task proxy creation.
- **Fix applied**: Same as S6-6 — rewrote `showTaskWithId()` to avoid `clearTask()` race condition.
- **Verification**: Click a history item, verify the chat view loads and stays visible with the task's messages.
### S6-24: Tool use blocks ("Cline wants to create a new file") are empty
- **Status**: 🟢 Verified Fixed
- **Description**: When the agent uses tools (e.g., `editor`), the tool use block in the chat showed the header ("Cline wants to create a new file") but the content area was empty — no file path, no diff, no content preview.
- **Root cause**: The `content_end` event for tools does NOT carry the tool's `input` (path, content, etc.). The message translator was passing `undefined` as the input to `sdkToolToClineSayTool()` at `content_end`, resulting in a `ClineSayTool` with empty `path`, `content`, and `diff` fields. The `content_start` event DOES carry the input, but it wasn't being preserved for use at `content_end`.
- **Fix applied**: Three changes to `src/sdk/message-translator.ts`:
1. Added `streamingToolInput` and `streamingToolName` fields to `MessageTranslatorState` to store the tool context from `content_start`.
2. At `content_start` for tools, store the input via `state.setStreamingToolContext(toolName, input)`.
3. At `content_end` for tools, retrieve the stored input via `state.getStreamingToolInput()` and pass it to `sdkToolToClineSayTool()` instead of `undefined`.
4. The stored context is cleared in `clearStreamingTool()` and `reset()`.
- **Verification**: 4 new unit tests verify: (1) editor edit preserves path+content through content_start→content_end, (2) newFileCreated preserves content, (3) read_files preserves path, (4) graceful fallback when content_end arrives without prior content_start.
- **Evidence**: `npx vitest run --config vitest.config.sdk.ts src/sdk/message-translator.test.ts` — 34/34 pass.
### S6-25: Streaming text appears in "flip book" style instead of smooth append
- **Status**: 🟢 Verified Fixed (same root cause as S6-21)
- **Description**: During streaming, the AI response text appeared in a "flip book" style — the entire message content flashed and replaced itself on each chunk, rather than smoothly appending new characters.
- **Root cause**: Same as S6-21. The message translator was using `event.text` (the delta) instead of `event.accumulated` (the full text so far). Each streaming update replaced the message content with just the new chunk instead of the growing accumulated text.
- **Fix applied**: Same as S6-21 — changed `message-translator.ts` to use `event.accumulated ?? event.text` for streaming text. All streaming chunks now share the same timestamp and use accumulated text, giving smooth in-place updates.
- **Verification**: 3 new unit tests verify: (1) accumulated text is used over delta, (2) fallback to text when accumulated is absent, (3) all streaming chunks share the same timestamp.
- **Evidence**: `npx vitest run --config vitest.config.sdk.ts src/sdk/message-translator.test.ts` — 34/34 pass.
### S6-26: SDK pending prompts / tool approval / ask_question not integrated
- **Status**: 🔴 Blocker
- **Description**: The SDK has three mechanisms for the agent to interact with the user mid-task, none of which are currently wired into the VSCode extension:
**1. `requestToolApproval` callback** — When a tool's policy has `autoApprove: false`, the agent calls `requestToolApproval({ agentId, conversationId, iteration, toolCallId, toolName, input, policy })` and blocks until the callback returns `{ approved: boolean, reason?: string }`. Without this callback, ALL non-auto-approved tools are denied with "no approval handler is configured". This is the equivalent of the classic extension's "Cline wants to..." approval dialog.
**2. `ask_question` tool executor** — The SDK has a built-in `ask_question` tool (equivalent to the classic `ask_followup_question`). It requires an `askQuestion` executor function passed via `defaultToolExecutors: { askQuestion: fn }`. The executor receives `(question, options, context)` and returns the user's answer as a string. Without this executor, the tool is excluded from the agent's tool list entirely. The CLI implements this as `askQuestionInTerminal` which prompts in the terminal.
**3. Pending prompts system** — When the user sends a message while the agent is already running, `send()` with `delivery: "queue"` or `delivery: "steer"` enqueues the message as a pending prompt. The SDK emits `pending_prompts` events with the current queue snapshot, and `pending_prompt_submitted` events when a queued prompt is consumed. The `drainPendingPrompts()` method processes the queue when the agent is idle. `"steer"` prompts go to the front of the queue; `"queue"` prompts go to the back. The Tauri desktop app and CLI TUI both subscribe to these events to show queued messages in the UI.
- **Root cause**: The `VscodeSessionHost` currently passes no `requestToolApproval` callback and no `defaultToolExecutors.askQuestion`. The `SdkController.askResponse()` method sends with no `delivery` parameter (defaults to "immediate"), which blocks if the agent is already running.
- **Impact**:
- Tools that require approval are silently denied → agent can't use file editing, commands, etc. unless everything is auto-approved
- Agent can't ask the user clarifying questions → `ask_question` tool is missing from the tool list
- User can't send follow-up messages while the agent is running → `send()` throws "already in progress"
- **Fix needed** (three parts):
**Part A: `requestToolApproval` callback (~50 lines)**
Wire into `VscodeSessionHost.create()` options. The callback should:
1. Emit a ClineMessage with `type: "ask"`, `ask: "tool"` containing the tool name and input as `ClineSayTool` JSON (same format the classic extension uses for tool approval dialogs)
2. Add the message to `messageStateHandler` and push to the partial message stream
3. Return a Promise that resolves when the user clicks Approve/Reject in the webview
4. The webview's existing approval UI (Approve/Reject buttons in ChatRow) already sends `askResponse` back through gRPC → `SdkController.askResponse()`. Need to wire this to resolve the approval Promise.
**Reference**: CLI implementation at `apps/cli/src/utils/approval.ts:63-108`. Desktop implementation at `apps/desktop/hooks/use-agent-session.tsx:134-194` (polls for approvals via `poll_tool_approvals` Tauri command). Tauri desktop at `apps/code/hooks/use-chat-session.ts:993-1010` (responds via `respond_tool_approval`).
**Part B: `askQuestion` executor (~30 lines)**
Wire into `VscodeSessionHost.create()` via `defaultToolExecutors: { askQuestion: fn }`. The executor should:
1. Emit a ClineMessage with `type: "ask"`, `ask: "followup"` containing the question and options
2. Return a Promise that resolves with the user's text response when they reply in the webview
3. The webview's existing follow-up question UI already handles this message type
**Reference**: CLI implementation at `apps/cli/src/runtime/run-interactive.ts:106` (`askQuestionInTerminal`).
**Part C: Pending prompts for follow-up messages (~20 lines)**
Update `SdkController.askResponse()` to use `delivery: "queue"` when the agent is running, so follow-up messages are queued instead of throwing. Subscribe to `pending_prompts` and `pending_prompt_submitted` events to show queued messages in the webview.
**Reference**: CLI wiring at `apps/cli/src/runtime/run-interactive.ts:125-134`. Tauri desktop at `apps/code/host/runtime-bridge.ts:338-382`.
- **Verification**:
1. Start a task that uses tools → verify approval dialog appears → approve → tool executes
2. Start a task where the agent calls `ask_question` → verify question appears in chat → answer → agent continues
3. While agent is running, send a follow-up message → verify it queues and is processed after the current turn
### S6-27: History messages not rendering when opened (S6-6 still broken)
- **Status**: 🟢 Verified Fixed
- **Description**: Clicking a history item (from the welcome page's "Recent" section or the history view) did not render the task's messages. The chat view either stayed on the welcome page or showed no messages.
- **Root cause**: The gRPC handler `src/core/controller/task/showTaskWithId.ts` was calling `controller.initTask(undefined, undefined, undefined, historyItem)` which started a **new SDK session** instead of loading the existing task's messages from disk. The `SdkController.initTask()` method creates a new session, new task proxy, and new history item — it does NOT load saved messages. Meanwhile, `SdkController.showTaskWithId()` (which correctly loads messages from disk, creates a task proxy with those messages, and pushes them to the webview) was never being called.
- **Fix applied**: Changed `src/core/controller/task/showTaskWithId.ts` to call `controller.showTaskWithId(id)` instead of `controller.initTask(...)`. The `SdkController.showTaskWithId()` method handles: (1) looking up the history item, (2) tearing down any active session, (3) creating a task proxy with loaded messages, (4) pushing messages through both state updates and partial message stream, (5) posting state to the webview.
- **Verification**: Debug harness test on 2026-04-16: (1) Sent "Say hello world test", inference completed with "Hello world test! 👋". (2) Clicked "New Task" to navigate to welcome page. (3) Clicked the history item from the "Recent" section. (4) Chat view loaded with all 5 messages: task, api_req_started, text response, api_req_started with tokens, completion_result.
- **Evidence**: Debug harness session on 2026-04-16. Messages confirmed saved to `ui_messages.json` at `HostProvider.globalStorageFsPath/tasks/<id>/`. Both direct gRPC call and click-based navigation verified.
---
## Priority & Next Steps
**Current state (updated 2026-04-20)**: Inference works end-to-end. History open/resume flow is working, MCP OAuth + provider OAuth callbacks are implemented, and MCP tool reload preserves task/session continuity. Tool-call rendering in chat has been improved (including multi-file `read_files`).
### 🟢 Resolved: S6-27 — History messages not rendering
Fixed. The gRPC handler was calling `controller.initTask()` (starts new session) instead of `controller.showTaskWithId()` (loads messages from disk). See S6-27 entry for details.
### 🔴 Top Priority: S6-26 — Pending prompts / tool approval / ask_question
The SDK's three user-interaction mechanisms are not wired in. Without `requestToolApproval`, non-auto-approved tools are silently denied. Without `askQuestion`, the agent can't ask clarifying questions. Without pending prompts, follow-up messages during a running task will fail.
### 🔴 Third Priority: S6-18 — Missing API key shows error instead of login prompt
When not logged in with the "cline" provider, the user sees a raw error instead of a login prompt. This blocks the first-run experience.
### 🟡 Lower Priority:
- S6-17: Cancel button state
- S6-2: OCA and Codex OAuth flows not yet verified
- S6-7: Credits/payment history don't load immediately
### S6-28: MCP tool reload messages appear twice in chat
- **Status**: 🟢 Verified Fixed
- **Description**: When saving the MCP settings file (triggering a tool list change), the info messages "MCP tools changed — reloading tools for this session..." and "MCP tools reloaded successfully." each appeared TWICE in the chat. The tool reload itself worked correctly — only the messages were duplicated.
- **Root cause**: `notifyWebviewOfServerChanges()` in McpHub fires multiple times in quick succession when a server connects (status change → tools discovered → etc.). Each call triggered `checkToolListChanged()` which detected the fingerprint change and fired the callback. The callback fired multiple times before the fingerprint was updated, causing duplicate messages.
- **Fix applied**: Added 300ms debounce to `checkToolListChanged()` in `McpHub.ts`. The method now: (1) quick-checks the fingerprint — if unchanged, returns immediately without scheduling a timer, (2) if changed, debounces via `setTimeout(300ms)` to coalesce rapid-fire changes, (3) after the debounce, `fireToolListChangeIfNeeded()` re-checks the fingerprint and fires the callback only if it actually changed.
- **Verification**: Save MCP settings file, verify each message appears exactly once.
### S6-30: Follow-up messages silently dropped after task completion
- **Status**: 🟢 Verified Fixed
- **Description**: After a task completed, typing a follow-up message and pressing Enter (or clicking Send) did nothing. The message appeared in the textarea but was never sent. The `ui.send_message` gRPC method worked (bypassing the webview's `handleSendMessage`), but DOM-level input was broken.
- **Root cause**: The webview's `handleSendMessage()` in `useMessageHandlers.ts` requires `clineAsk` to be set to send follow-up messages. The classic extension emits `ask: "completion_result"` when a task completes, which sets `clineAsk` in the webview. The SDK's message translator was emitting `say: "completion_result"` (a display-only message) instead of `ask: "completion_result"` (which enables the follow-up input). Without the ask message, `handleSendMessage()` fell through to the "task is running" check (which was false since the task was complete), and the message was silently dropped (`messageSent` stayed `false`).
- **Fix applied**: Changed `src/sdk/message-translator.ts` to emit `type: "ask", ask: "completion_result"` instead of `type: "say", say: "completion_result"` for the `done` agent event. Only the ask is emitted (not both say+ask) to avoid duplicate "Task Completed" displays in the webview.
- **Verification**: Debug harness test on 2026-04-17: (1) Sent "Say hello" via `ui.send_message`, task completed. (2) Typed "Now say goodbye" via `ui.react_input` with `submit: true`. (3) Follow-up inference ran and returned "Goodbye! 👋". (4) Also tested MCP tools in follow-up turns — `kb_search` worked correctly.
- **Evidence**: Debug harness session on 2026-04-17.
### S6-29: MCP tool reload leaves UI in "Thinking..." state, blocking follow-ups
- **Status**: 🟢 Verified Fixed
- **Description**: After an MCP tool reload (triggered by toggling a server in the MCP panel), the chat showed "MCP tools changed" and "MCP tools reloaded" info messages but the UI was left in a "Thinking..." state. Follow-up messages could not be sent because the webview's `handleSendMessage()` requires `clineAsk` to be set.
- **Root cause**: `restartSessionForMcpTools()` emitted `say: "info"` messages for the reload status but did NOT emit `ask: "completion_result"` afterward. Without the ask message, `clineAsk` was not set in the webview, so `handleSendMessage()` silently dropped follow-up input.
- **Fix applied**: After the success info message in `restartSessionForMcpTools()`, emit an `ask: "completion_result"` message with empty text. This tells the webview the agent is idle and enables the follow-up input.
- **Verification**: Debug harness test on 2026-04-17: (1) Sent "Say hello briefly", task completed. (2) Toggled kamibiki MCP server off via UI. (3) "MCP tools changed" + "MCP tools reloaded" messages appeared (no "Thinking..." state). (4) Typed "Say goodbye" via `ui.react_input` — follow-up inference ran and returned "Goodbye! 👋".
- **Evidence**: Debug harness session on 2026-04-17.
### S6-31: Conversation history lost after MCP tool changes (session recreated)
- **Status**: 🟢 Verified Fixed
- **Description**: MCP-triggered session restarts now preserve active task/session continuity, preventing chat/task state loss after toggling MCP servers.
- **Root cause**: Session recreation could break task/session linkage in webview state.
- **Fix applied**: In `restartSessionForMcpTools()` (`src/sdk/SdkController.ts`), set `config.sessionId = oldSessionId` and keep the task ID stable even if SDK returns a different ID, with warning log fallback. This keeps `currentTaskItem` mapping intact during MCP reloads.
- **Verification**: Toggle MCP server while chat is active, verify task remains active and state continuity is preserved.
- **Evidence**: Commit `b2db4937a` (“preserve task session id when reloading MCP tools”).
### S6-32: "New Task" button and task delete disabled after MCP tool change
- **Status**: 🟢 Verified Fixed
- **Description**: Button-state lockups after MCP tool changes are resolved.
- **Root cause**: UI/task continuity broke when MCP restarts changed session identity/state linkage.
- **Fix applied**: Same core fix as S6-31 (`b2db4937a`) keeps task/session identity stable during MCP reloads, preventing webview state from drifting into a pseudo-running state.
- **Verification**: After MCP toggle, verify New Task and delete actions remain enabled/functional.
- **Evidence**: Commit `b2db4937a`.
### S6-33: Insufficient credits shows raw error text instead of buy-credits UI
- **Status**: 🔴 Blocker
- **Description**: When attempting inference with no credits (negative balance), the chat displays the raw error text "Insufficient balance. Your Cline Credits balance is $-0.14" followed by "Thinking..." that spins forever. The classic extension shows an interactive error state with buttons to buy credits, switch providers, etc. The SDK error is displayed as plain text with no actionable UI.
- **Root cause**: The SDK throws an error (or emits an error event) when the API returns a 402/insufficient-balance response. The `SdkController` or message translator doesn't distinguish this error type from generic API errors. In the classic extension, `attemptApiRequest()` catches balance errors specifically and emits `ask: "api_req_failed"` with structured error info that the webview's `ChatRow.tsx` renders with buy-credits buttons and provider-switching options. The SDK adapter just displays the error text as a `say: "error"` message, which has no interactive UI.
- **Fix**: Not yet attempted. The error handler in `SdkController` (or the message translator's error event handler) needs to detect insufficient-balance errors (check for 402 status, "insufficient balance" text, or SDK-specific error types) and emit `ask: "api_req_failed"` with the appropriate structured payload that the webview expects for rendering the buy-credits UI.
- **Verification**: Log in with an account that has no credits, attempt inference, verify the buy-credits buttons and provider-switch options appear instead of raw error text.
### S6-34: Cancel during generation doesn't show "Resume task" and follow-ups don't display
- **Status**: 🔴 Blocker
- **Description**: Two related issues when cancelling during active generation: (1) After hitting "Cancel" while the agent is streaming, the button does not change to "Resume task" — it stays in a stuck state without the expected resume option. (2) If the user sends another message after cancelling, the message does not display in the chat panel (though it may be sent to the backend).
- **Root cause**: The classic extension emits `ask: "resume_task"` when a task is cancelled mid-generation, which tells the webview to show the "Resume task" button and enables the follow-up input. The SDK adapter's `cancelTask()` likely calls `sessionManager.abort()` or `sessionManager.stop()` but doesn't emit the `ask: "resume_task"` message afterward. Without this ask message, the webview doesn't know the task is in a resumable state — the button state is wrong and `handleSendMessage()` doesn't handle the follow-up correctly. The follow-up message not displaying is likely the same root cause as S6-30 — the webview's `clineAsk` is not set to a value that enables message sending/display.
- **Fix**: Not yet attempted. After `cancelTask()` successfully aborts the session, emit `ask: "resume_task"` (or `ask: "resume_completed_task"` depending on whether the task had completed) to the message state handler and partial message stream. This mirrors the classic extension's behavior in `Task.abortTask()` which emits the resume ask message. Also need to ensure the follow-up message handler works correctly when resuming from a cancelled state.
- **Verification**: Debug harness test: (1) Send a message that triggers long generation (2) Hit Cancel during streaming (3) Verify "Resume task" button appears (4) Send a follow-up message (5) Verify it displays in chat and triggers inference
<!-- Template:
### [ID] Title
- **Status**: 🔴/🟡/🔵/🟢
- **Description**: What's wrong
- **Root cause**: If known
- **Fix**: If attempted, with file references
- **Verification**: How to verify (test name, harness command)
- **Evidence**: Test output, screenshot, etc. (required for 🟢)
-->
### S6-35: Inference cost not displayed in task
- **Status**: 🟡 Minor
- **Description**: During and after inference, the cost/token usage is not displayed in the task's chat view. The classic extension shows token counts (input/output/cache) and cost in the `api_req_started` message block. The SDK adapter emits `api_req_started` messages but likely doesn't populate the cost/token fields, or the `usage` event from the SDK isn't being translated into the format the webview expects.
- **Root cause**: The SDK emits `usage` events (with `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `totalCost`) via `agent_event` with `type: "usage"`. The message translator likely creates `api_req_started` messages without the cost JSON payload, or doesn't update them with final usage data when the `usage` event arrives. The webview's `ApiRequestRow` component expects `api_req_started` messages to have a `text` field containing JSON with `{tokensIn, tokensOut, cacheReads, cacheWrites, cost}`.
- **Fix**: Not yet attempted. The message translator needs to: (1) emit `api_req_started` at the beginning of each API request with initial data, and (2) update it with cost/token data when the SDK's `usage` event arrives (or at `iteration_end`/`done`). The update should match the JSON format that `ApiRequestRow` expects.
- **Verification**: Send a message, verify that token counts and cost appear in the collapsible API request row in the chat.
### S6-36: Returning to an in-progress task after clicking New Task shows stale "Thinking..."
- **Status**: 🟢 Verified Fixed
- **Description**: If the user clicked New Task mid-generation and later reopened the old task, the old task could still appear as streaming/"Thinking..." due to partially persisted messages.
- **Root cause**: Task clear/load paths could persist partial messages without finalization, so reopening rendered stale streaming state.
- **Fix applied**: `clearTask()` now finalizes messages before save (removes `partial`, marks last unfinished `api_req_started` as `cancelReason: "user_cancelled"`), and `showTaskWithId()` sanitizes loaded messages + appends the appropriate resume ask (`resume_task` or `resume_completed_task`).
- **Verification**: Click New Task during a running task, reopen previous task, verify it no longer appears stuck in "Thinking...".
- **Evidence**: Commit `70b5ff110`.
### S6-37: Tool-call rendering gaps (multi-file read_files + post-tool assistant text)
- **Status**: 🟢 Verified Fixed
- **Description**: Two rendering gaps remained in chat tool-call UX: (1) `read_files` with multiple files showed only one file path, and (2) assistant text after tool results could be dropped by low-stakes tool grouping.
- **Root cause**:
1. Translator extracted only the first file path for `read_files`.
2. `groupLowStakesTools()` ignored text that arrived after a tool group had started.
- **Fix applied**:
1. `message-translator.ts` now emits one `readFile` tool message per file for multi-file reads.
2. `messageUtils.ts` now commits active tool groups before handling subsequent text, preserving post-tool assistant summaries.
3. Additional SDK tool-name mappings were added (`execute_command`, `write_to_file`, `search_files`, etc.) to improve ChatView tool rendering compatibility.
- **Verification**: Run prompt paths that trigger multi-file reads and then assistant summary text; verify all files are listed and assistant text remains visible.
- **Evidence**: Commits `bc3590534` and `26614a007`, plus added tests in `src/sdk/message-translator.test.ts` and `webview-ui/src/components/chat/chat-view/utils/messageUtils.test.ts`.
+554
View File
@@ -0,0 +1,554 @@
# SDK Migration — Entry Point
You are working on migrating the Cline VSCode extension from its
classic core to the Cline SDK (`@clinebot/core`). This document is
your primary reference. Read it in full before starting any step.
## Document Map
| Document | Purpose | When to Read |
|----------|---------|--------------|
| **This file** | Entry point, plan, operational procedure | Always, first |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Features, design decisions, SDK capabilities | Before Step 1; refer back as needed |
| [SDK-REFERENCE/OAUTH.md](SDK-REFERENCE/OAUTH.md) | How the SDK handles OAuth and credentials | When working on auth (Steps 4, 5) |
| [SDK-REFERENCE/MCP.md](SDK-REFERENCE/MCP.md) | How the SDK handles MCP server management | When working on MCP (Step 5) |
| [PROBLEMS.md](PROBLEMS.md) | Known issues, verification status | Before each verification gate |
| [../src/dev/debug-harness/README.md](../src/dev/debug-harness/README.md) | Debug harness API reference | When using the debug harness |
Docs from previous attempts that are **not** carried forward:
- CAVEATS.md, FIXED.md, FEATURE-REMOVAL-CLEANUP-PLAN.md,
DEBUG-HARNESS.md (root level), FEEDBACK.md — these degraded badly.
Lessons are incorporated into this plan.
## References
### Code Repositories
| Repo | Path | kb_search name |
|------|------|----------------|
| Cline (this repo) | `~/clients/cline/cline` | `cline` |
| Cline SDK | `~/clients/cline/sdk-wip` | `sdk` |
| JetBrains Plugin | `~/clients/cline/intellij-plugin` | `plugin` |
| VSCode | `~/clients/cline/vscode` | `vscode` |
### How to Research the SDK
**Always use `kb_search` with the `sdk` repo** when you need to
understand how the SDK supports a feature. Do not guess at APIs,
URLs, or data formats. The SDK is the source of truth.
Example: Before implementing OAuth, search:
```
kb_search(name="sdk", query="OAuth login flow callback")
```
You can also compare before/after states using commit-based search:
```
kb_search(name="cline", query="accountLoginClicked", commit="origin/main")
kb_search(name="cline", query="accountLoginClicked", commit="HEAD")
```
---
## Core Principles
These principles are derived from hard-won experience on two previous
attempts. Violating them leads to broken products and wasted time.
### 1. Thunk, Don't Replace
The webview speaks gRPC-over-postMessage today. We will **not**
replace that with a new message protocol in this migration. Instead,
we build a **thunking layer** that sits between the SDK and the
existing gRPC interface. The webview continues to send gRPC-shaped
messages; the thunking layer translates between those and SDK calls.
This means:
- The webview code is largely untouched
- gRPC proto files stay in place until the final cleanup step
- Each SDK feature is wired up by implementing its gRPC handler
### 2. Verify Before You Proceed
Every step has a **verification gate**. You must demonstrate the
feature works before moving on. Verification means:
- Unit tests that test real behavior, not just that functions exist
- Debug harness smoke tests for UI-facing features
- Manual confirmation when automated tests can't cover it
Mark things as **"awaiting verification"** not "fixed". Only mark
"verified" after you have evidence (test output, screenshot, etc.).
### 3. Delete and Document
When replacing a classic module with its SDK equivalent, **delete the
classic code immediately** and document where to find it. Dead code
in the tree creates confusion about what is active vs. vestigial.
The classic implementation is always accessible via:
- `kb_search(name="cline", query="...", commit="origin/main")`
search the classic codebase at the pre-migration commit
- `git show origin/main:path/to/file.ts` — view any file
- `git diff origin/main..HEAD -- path/` — see what changed
When deleting a module, add a comment in the replacement file:
```
// Replaces classic src/core/task/ (see origin/main)
```
This way there is never any ambiguity about what code is running.
### 4. Use the Debug Harness
The debug harness at `src/dev/debug-harness/` is your primary
integration testing tool. Use it to:
- Verify UI renders correctly after changes
- Test user flows (login, chat, settings, history)
- Catch regressions that unit tests miss
**Always dismiss promotional overlays first.** There may be one or two:
1. "Introducing Cline Kanban" overlay
2. "New in v3.78.0" announcement overlay
Both follow the same `sr-only` pattern and can be dismissed with:
```
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
```
You may need to run this twice if both overlays are present.
Use VSCode command palette actions to navigate between tabs.
### 5. SDK "Default" Implementations Are References, Not Products
The SDK's `DefaultSessionBuilder`, `DefaultRuntimeBuilder`, etc. are
designed for simple use cases. As an IDE, we need more:
- Custom MCP manager (file watching, SSE/streamableHTTP support)
- Custom session persistence (read existing task history format)
- Custom tool approval (integrate with webview approval UI)
Use the defaults as references, but implement what the product needs.
### 6. Avoid `as` Casts and Type Confusion
A recurring bug source was confusion between SDK types and gRPC/proto
types. For example, SDK returns `accountId` but gRPC expects
`workos:accountId`. Use explicit conversion functions with tests, and
never use `as` to paper over type mismatches.
---
## Migration Steps
This plan is ordered by dependency: each step builds on the previous.
Do not skip steps. Each step ends with a verification gate.
### Step 1: Foundation & Cutover
**Goal:** SDK dependencies installed, test infrastructure ready,
and the extension's entry point switched to the SDK adapter.
There is one entry point, not two.
Tasks:
- Add `@clinebot/core`, `@clinebot/llms`, `@clinebot/shared`,
`@clinebot/agents` as dependencies (via `npm link` from local SDK)
- Add `vitest.config.sdk.ts` for SDK adapter tests
- Create `src/sdk/` directory with `index.ts` barrel export
- Modify `src/extension.ts` to use the SDK adapter as its
activation path (replacing the classic `Controller` import)
- Delete `src/core/controller/` — the classic controller is replaced
by `src/sdk/SdkController.ts` (to be implemented in Step 4).
Add comment: `// Replaces classic src/core/controller/ (see origin/main)`
- Update `esbuild.mjs` if needed for the new import structure
- Verify: `npm run compile` succeeds, extension loads in VSCode
(sidebar may show errors since handlers aren't implemented yet,
but the extension process itself starts)
**Why one entry point:** Attempt 2 used `CLINE_SDK=1` to switch
between two entry points. This caused constant confusion about which
codepath was running. With a single entry point, there is never any
doubt. The classic code is always accessible via `origin/main`.
**Verification gate:** Extension compiles and loads. The SDK adapter
is the only codepath. (It won't do much yet — that's Step 4.)
### Step 2: Legacy State Reader
**Goal:** Read all existing on-disk state from the SDK adapter layer.
Tasks:
- Implement `src/sdk/legacy-state-reader.ts`:
- Read `globalState.json` (provider settings, model selections,
dismissed banners, etc.)
- Read `secrets.json` (API keys, Cline auth tokens)
- Read `taskHistory.json` (task list for history view)
- Read per-task directories (`api_conversation_history.json`,
`ui_messages.json`)
- Read `cline_mcp_settings.json` (MCP server configs)
- Write tests against fixture data (copy real `~/.cline/data/`
samples, redact secrets)
- Verify: All reads produce correct typed results, error handling
for missing/corrupt files
**Verification gate:** Unit tests pass; reader correctly parses
real `~/.cline/data/` contents (spot-check manually).
### Step 3: Provider Migration
**Goal:** Existing provider credentials survive the transition.
Tasks:
- Implement `src/sdk/provider-migration.ts`:
- Use SDK's `migrateLegacyProviderSettings()` as reference
- Map classic `globalState.json` + `secrets.json` entries to
SDK `providers.json` format
- Never overwrite existing entries
- Tag migrated entries with `tokenSource: "migration"`
- Write a migration sentinel to prevent re-migration
- Test with fixtures covering all 30+ providers
- Verify: After migration, SDK can create handler for each provider;
existing API keys still work
**Critical:** This is the highest-risk step. Getting it wrong means
users get logged out. Test exhaustively.
**Verification gate:** All provider credential tests pass. Manual
test: set up providers in classic extension, switch to SDK branch,
verify inference still works for Anthropic, OpenAI, OpenRouter,
Ollama, and the Cline provider.
### Step 4: Session Lifecycle (No UI Yet) — ✅ Completed
**Goal:** Create and manage SDK sessions from the adapter layer.
Tasks:
- [x] Implement `src/sdk/cline-session-factory.ts`:
- Custom session persistence adapter reading `~/.cline/data/tasks/`
- Map `HistoryItem` ↔ session fields
- Implement `ClineCore.create()` with proper config
- Build `CoreSessionConfig` from legacy state via `ProviderSettingsManager`
- Build `StartSessionInput` and resume input helpers
- [x] Implement `src/sdk/SdkController.ts`:
- `initTask(prompt)` — create session, start inference
- `askResponse(message)` — continue conversation (sends to existing session)
- `cancelTask()` — abort running session
- `clearTask()` — reset for new task
- `showTaskWithId(id)` — load task from history
- `reinitExistingTaskFromId(id)` — resume task from history
- Subscribe to SDK events, translate to internal message format
- Session event listener system for downstream consumers
- [x] Implement `src/sdk/message-translator.ts`:
- SDK `CoreSessionEvent``ClineMessage[]` for webview consumption
- Handle all event types: chunk, agent_event (content_start/update/end,
done, error, notice, iteration_start/end, usage), ended, hook, status
- Streaming state tracking (partial message dedup)
- Tool text formatting helpers
- HistoryItem ↔ session field mapping
- [x] Test all paths — 91 unit tests pass across 4 test files
**Verification gate:** ✅ Unit tests pass (91/91). TypeScript compiles
with 0 errors in `src/sdk/`. Session lifecycle methods work through
the adapter layer without any UI. See PROBLEMS.md for known minor issues.
### Step 5: gRPC Thunking Layer — ✅ Completed
**Goal:** Wire SDK adapter to the existing webview via gRPC handlers.
This is the **critical insight from attempt 2**: the webview speaks
gRPC. We translate at the boundary. The webview stays untouched.
Tasks:
- [x] Implement `src/sdk/task-proxy.ts`:
- `TaskProxy` provides a classic Task-compatible interface that
delegates to SDK session methods
- `handleWebviewAskResponse()` → SdkController.askResponse()
- `abortTask()` → SdkController.cancelTask()
- `MessageStateHandler` extends EventEmitter for CLI compatibility
- `TaskProxyState` mirrors classic TaskState subset
- Stub properties for removed features (browser, checkpoints)
- [x] Implement `src/sdk/webview-grpc-bridge.ts`:
- Bridges SDK session events to webview gRPC streams
- Translates ClineMessages to proto format via `convertClineMessageToProto()`
- Pushes through `sendPartialMessageEvent()` for streaming
- Pushes through `sendStateUpdate()` on significant events
- Error handling — never blocks the event stream
- [x] Wire SdkController to use TaskProxy + WebviewGrpcBridge:
- Session events → message translation → gRPC bridge → webview
- `handleSessionEvent()` translates and emits to all listeners
- Messages accumulated in `messageStateHandler` for state building
- State updates pushed on turn complete / session ended
- [x] Reuse existing `getStateToPostToWebview()` for state building
- Classic implementation reads from StateManager
- TaskProxy provides `messageStateHandler.getClineMessages()`
- Will be gradually replaced with SDK-sourced state in later steps
**Verification gate:** ✅ 114 unit tests pass across 6 test files.
TypeScript compiles with 0 new errors (3 pre-existing in unrelated
files). The gRPC thunking layer is complete — session events flow
from SDK through message translation to webview gRPC streams.
See PROBLEMS.md for known minor issues.
### Step 6: Auth & Account Flows — ✅ Implementation Complete, 🔵 Awaiting E2E Verification
**Goal:** Full OAuth login/logout, credit display, org switching work.
This was the **most broken area** in attempt 2. Be especially careful.
Tasks:
- [x] Implement Cline OAuth using SDK's `loginClineOAuth()`:
- SDK spawns local callback server and provides the auth URL
- Our code opens the browser via `openExternal()`
- SDK handles token exchange
- We persist tokens to `secrets.json` under `cline:clineAccountId`
- [x] Implement `subscribeToAuthStatusUpdate` streaming:
- Read credentials from disk on subscription
- Push initial auth state immediately (prevents race condition)
- Cross-window sync via secrets change listener
- [x] Implement `getUserCredits` / `getOrganizationCredits`:
- Fetch from Cline API using stored auth token via `ClineAccountService`
- Use `{apiBaseUrl}` not hardcoded `app.cline.bot`
- [x] Implement `accountLogoutClicked`:
- Clear credentials from disk
- Push unauthenticated state to webview
- [x] Implement `setUserOrganization`:
- Update active org via API call
- Refresh auth info after switching
- [x] Implement OpenAI Codex OAuth via SDK's `loginOpenAICodex()`
- [x] Implement OCA OAuth via SDK's `loginOcaOAuth()`
- [x] Implement token refresh using SDK's `refreshClineToken()`
- [x] Write unit tests — 20 tests in `src/sdk/auth-service.test.ts`
**Key pitfalls from attempt 2 (all addressed):**
- `workos:` prefix on account IDs — `getAuthToken()` always returns `workos:`-prefixed token
- `{appBaseUrl}` vs hardcoded URLs — uses `ClineEnv.config().apiBaseUrl` and `appBaseUrl`
- Race condition: webview subscribes to auth state before the
bridge pushes it — `subscribeToAuthStatusUpdate` pushes initial state immediately
- Token field name mismatches between SDK and classic storage —
explicit conversion in `credentialsToAuthInfo()` (ms→seconds for expiresAt)
**Files created/modified:**
- `src/sdk/auth-service.ts` — SDK-backed AuthService (replaces `src/services/auth/AuthService.ts`)
- `src/sdk/account-service.ts` — SDK-backed ClineAccountService (replaces `src/services/account/ClineAccountService.ts`)
- `src/sdk/auth-service.test.ts` — 20 unit tests
- `src/sdk/SdkController.ts` — Wired auth/account services in constructor
- `src/sdk/index.ts` — Added barrel exports
- `src/core/controller/account/accountLoginClicked.ts` — Import from `@/sdk/auth-service`
- `src/core/controller/account/accountLogoutClicked.ts` — Delegates to SdkController
- `src/core/controller/account/subscribeToAuthStatusUpdate.ts` — Import from `@/sdk/auth-service`
- `src/core/controller/account/openAiCodexSignIn.ts` — Uses SDK-backed AuthService
- `src/core/controller/account/openAiCodexSignOut.ts` — Uses SDK-backed AuthService
- `src/extension.ts` — Import from `@/sdk/auth-service`
**Verification gate:** 🔵 Unit tests pass (20/20). TypeScript compiles
with 0 new errors. End-to-end verification with debug harness pending —
need to test: login flow, profile display, credits, org switching, logout.
### Step 7: MCP Integration — ✅ Classic McpHub Wired (SDK Manager Deferred)
**Goal:** MCP servers load, tools appear in agent, server management
UI works.
Following the "Thunk, Don't Replace" principle, we wire the classic
`McpHub` into the SdkController instead of building a custom SDK MCP
manager. The classic McpHub already supports all three transports
(stdio, SSE, streamableHTTP), file watching, and all gRPC handlers.
The SDK's `InMemoryMcpManager` will replace it in Step 10 (Cleanup).
Tasks:
- [x] Wire classic `McpHub` into `SdkController.mcpHub`
- Same constructor args as classic Controller
- Existing gRPC handlers (`subscribeToMcpServers`, `restartMcpServer`,
`deleteMcpServer`, `toggleMcpServer`, etc.) work without modification
- They all delegate to `controller.mcpHub` which is now a real instance
- [x] Update `SdkController.mcpHub` type from `any` to `McpHub`
- [ ] Implement MCP marketplace (cache + refresh from API) — deferred
- [ ] Replace classic McpHub with SDK's InMemoryMcpManager — deferred to Step 10
**Reference:** See `SDK-REFERENCE/MCP.md` for how the SDK's MCP
manager works and what gaps exist.
**Verification gate:** 🔵 Classic McpHub wired in. Existing gRPC
handlers should work. Full E2E verification pending debug harness
test with real MCP servers configured.
### Step 8: Settings & Features — ✅ Core Settings Working
**Goal:** All settings UI works, feature toggles persist.
Following the "Thunk, Don't Replace" principle, the existing
`updateSettings` gRPC handler already works — it calls
`controller.stateManager.setGlobalState()` which is available.
We just needed to ensure TaskProxy properties don't crash it.
Tasks:
- [x] Wire all `updateSettings` keys to persist to `globalState.json`
— already works via StateManager
- [x] TaskProxy.api is settable (updateSettings replaces it on model switch)
- [x] TaskProxy.terminalManager safely no-ops (settings compatibility)
- [x] Implement `togglePlanActMode()` — saves mode, cancels active task
- [x] Implement `toggleActModeForYoloMode()` — switches to act mode
- [ ] Implement `getAvailableTerminalProfiles` (simplified — only
background terminal) — deferred
- [ ] Simplify terminal settings UI (remove IDE terminal options) — deferred
- [ ] Remove workflows tab from Cline Rules modal — deferred
- [ ] Remove focus chain / deep planning / memory bank UI remnants — deferred
- [ ] Verify model picker works for all providers — needs E2E test
- [ ] Verify Plan/Act mode toggle works with separate model configs — needs E2E test
**Verification gate:** 🔵 Core settings work (updateSettings, mode toggle).
Full E2E verification pending debug harness test with real credentials.
UI cleanup items deferred to post-Step-9 polish.
### Step 9: Full Integration Verification
**Goal:** The SDK-backed extension is functionally equivalent to the
classic extension for all core features.
Tasks:
- Write QA test scripts covering:
1. Fresh install flow (no saved state)
2. Upgrade flow (existing state from classic)
3. Login → inference → logout → login
4. Multiple providers (Cline, Anthropic, OpenAI, Ollama)
5. Task history: create, view, resume, delete, favorite
6. Settings: change model, change provider, toggle features
7. MCP: add server, use tool, remove server
8. Plan/Act mode switching
9. @ mentions and file attachments
10. Cancel task mid-execution, start new task
- Run each test with the debug harness
- Document any known issues in PROBLEMS.md with reproduction steps
**Verification gate:** All QA scripts pass. Any failures are
documented and triaged.
### Step 10: Cleanup (Only After Step 9 Passes)
**Goal:** Remove classic core code that is no longer used.
**Do NOT start this step until Step 9 is fully verified.**
Tasks:
- Delete `src/core/task/` (replaced by `@clinebot/agents`)
- Delete `src/core/controller/` (replaced by SDK adapter)
- Delete `src/core/api/` (replaced by `@clinebot/llms`)
- Delete `src/core/prompts/system-prompt/` (replaced by SDK prompts)
- Delete `src/services/mcp/McpHub.ts` (replaced by SDK MCP)
- Delete `src/standalone/` (not needed for VSCode)
- Remove deprecated feature code (browser automation, shadow git,
memory bank, focus chain, deep planning, workflows)
- Remove proto files for webview messages (keep proto for any
persisted state that still uses them)
- Remove proto build steps from `package.json`
- Remove `src/shared/proto-conversions/`, `src/generated/`
- Clean up imports, fix TypeScript errors
- Run full test suite
**Verification gate:** Extension compiles and loads. All QA scripts
from Step 9 still pass. `npm run compile` produces no errors.
### Future Steps (Not In Scope)
- Step 11: JetBrains sidecar (JSON-RPC over stdio)
- Step 12: Enterprise features (remote config, SSO, team controls)
- Step 13: Improved checkpoints (kanban-style git refs)
- Step 14: MCP Marketplace improvements
- Step 15: Remove gRPC thunking layer, switch webview to typed
JSON messages (optional — only if the thunking layer is a
maintenance burden)
---
## Operational Procedure
### How to Work on a Step
1. **Read the step description** in full
2. **Check PROBLEMS.md** for any known issues in this area
3. **Research the SDK** using `kb_search(name="sdk", query="...")`
before implementing anything
4. **Implement** the minimum needed to make the step's verification
gate pass
5. **Write tests** that verify real behavior
6. **Verify** using the debug harness for UI-facing features
7. **Update PROBLEMS.md** with any issues found, marked as
"awaiting verification"
8. **Commit** with a descriptive message referencing the step number
### How to Use the Debug Harness
```bash
# Build and launch
npx tsx src/dev/debug-harness/server.ts --skip-build --auto-launch
# Dismiss promotional overlays FIRST (may need to run twice)
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
# Navigate using command palette, NOT by clicking tabs
curl localhost:19229/api -d '{"method": "ui.command_palette", "params": {"command": "cline.accountLogin"}}'
# Take screenshots (read the file, don't open it!)
curl localhost:19229/api -d '{"method": "ui.screenshot"}'
# Returns {"result": {"path": "/tmp/cline-debug/screenshot-0001.png"}}
# Use read_file on that path to examine it
```
### How to Report Problems
When you find a bug, add it to `PROBLEMS.md` with:
- **ID**: Sequential number
- **Status**: 🔴 Blocker / 🟡 Minor / 🟢 Verified Fixed
- **Description**: What's wrong, where, how to reproduce
- **Root cause**: If known
- **Fix**: If attempted, with file references
- **Verification**: How to verify it's fixed (test name, harness
command, etc.)
**Never mark a problem 🟢 without evidence.** Write the test first,
then mark it fixed.
### How to Handle "SDK Doesn't Support X"
If the SDK is missing a feature you need:
1. Document the gap in PROBLEMS.md
2. Search the SDK codebase (`kb_search name="sdk"`) for any
workaround or extension point
3. If no workaround exists, implement a minimal version in the
adapter layer
4. File an issue / PR to the SDK repo for the proper fix
5. Use `npm link` for quick iteration on SDK changes
---
## What Changed From Previous Attempts
### Attempt 1 (sdk-migration-port-check)
Deleted ~138K lines of classic core before having a working
replacement. Created stub webview components. Result: 595 TypeScript
errors, non-functional extension.
**Lesson:** Delete and document — but only as you replace, not
before. The classic code is always accessible via `origin/main`.
### Attempt 2 (sdk-migration-v2, 90 commits)
Built an SDK adapter layer with tests. Got inference working.
But documentation degraded, bugs were marked fixed without
verification, auth flows were broken, gRPC "mode" vs SDK "mode"
caused confusion, feature removals were incomplete, and the
agent kept confusing SDK types with gRPC types. The dual entry
point (`CLINE_SDK=1`) was a constant source of confusion.
**Lessons applied in this plan:**
- Single entry point — no `CLINE_SDK` flag, no dual codepaths
- Delete and document — dead code creates confusion; use
`origin/main` and `kb_search` to reference the classic impl
- Thunk at the gRPC boundary, don't create a "mode" system
- Verify before proceeding, don't mark things fixed prematurely
- Use kb_search to research the SDK, don't guess at APIs
- Don't hardcode URLs, use `{appBaseUrl}`
- SDK "Default" implementations are references, not products
- Keep docs focused and reliable; use PROBLEMS.md for tracking
</task_progress>
</write_to_file>
+162
View File
@@ -0,0 +1,162 @@
# Cline SDK — MCP Server Management Reference
How the SDK handles MCP server lifecycle, configuration, and the gaps
that the adapter layer must fill. For the migration plan, see
[../README.md](../README.md).
## Summary
The SDK **does** provide a full MCP manager with lifecycle operations.
The actual gap is narrower than it first appears:
- No built-in file-watcher for `cline_mcp_settings.json`
- No RPC layer exposure of MCP management
- Default client factory only creates stdio clients (no SSE/streamableHTTP)
## SDK Architecture for MCP
### Layer 1: Settings File
`cline_mcp_settings.json` — JSON with `{ mcpServers: { ... } }`.
SDK utilities (all from `@clinebot/core`):
- `resolveDefaultMcpSettingsPath()` — find the file
- `hasMcpSettingsFile()` — check existence
- `loadMcpSettingsFile()` — parse and validate with Zod
- `resolveMcpServerRegistrations()` — parse → `McpServerRegistration[]`
- `registerMcpServersFromSettingsFile(manager)` — register all into a manager
### Layer 2: McpManager (`InMemoryMcpManager`)
```typescript
interface McpManager extends McpToolProvider {
registerServer(registration: McpServerRegistration): Promise<void>
unregisterServer(serverName: string): Promise<void>
connectServer(serverName: string): Promise<void>
disconnectServer(serverName: string): Promise<void>
setServerDisabled(serverName: string, disabled: boolean): Promise<void>
listServers(): readonly McpServerSnapshot[]
refreshTools(serverName: string): Promise<readonly McpToolDescriptor[]>
callTool(request: McpToolCallRequest): Promise<McpToolCallResult>
dispose(): Promise<void>
}
```
Key behaviors:
- Lazy connection (connect on first `listTools()` or `callTool()`)
- Transport change detection (reconnect if config changes)
- Per-server operation locks (no concurrent connect/disconnect races)
- Tool caching with TTL (5s default; use `refreshTools()` to force)
### Layer 3: McpServerClient (Transport)
Default factory (`createDefaultMcpServerClientFactory()`) creates
`StdioMcpClient` instances only.
| Transport | Status |
|-----------|--------|
| `stdio` | ✅ Fully implemented |
| `sse` | ⚠️ Type defined, no built-in client |
| `streamableHttp` | ⚠️ Type defined, no built-in client |
**We must provide a custom `McpServerClientFactory`** that handles
all three transports.
### Layer 4: Tool Bridge
`createMcpTools()` converts MCP server tools into SDK `Tool` objects.
Default name transform: `{serverName}__{toolName}` (e.g. `docs__search`).
MCP tools are indistinguishable from built-in tools once created.
## How the Runtime Builder Uses MCP
`DefaultRuntimeBuilder.build()`:
1. Resolves MCP settings file path
2. Creates fresh `InMemoryMcpManager`
3. Calls `registerMcpServersFromSettingsFile()`
4. Creates `Tool[]` via `createMcpTools()` for each non-disabled server
5. Returns tools + `shutdown()` callback
**Critical limitation**: This is done once at session build time.
No file watcher. No mid-session reload. The manager is encapsulated
and not exposed to callers.
## What the Adapter Layer Must Do
### Custom MCP Manager (Not SDK Default)
We need our own MCP manager that:
1. Reads from `cline_mcp_settings.json` on startup
2. Watches the file for changes (using `chokidar` or `fs.watch`)
3. Re-registers/reconnects servers when config changes
4. Supports stdio, SSE, and streamableHTTP transports
5. Exposes the manager for gRPC handlers (restart, toggle, delete)
### Custom Client Factory
```typescript
const clientFactory: McpServerClientFactory = async (registration) => {
if (registration.transport.type === "stdio") {
return createDefaultMcpServerClientFactory()(registration)
}
if (registration.transport.type === "streamableHttp") {
return new StreamableHttpMcpClient(registration)
}
if (registration.transport.type === "sse") {
return new SseMcpClient(registration)
}
throw new Error(`Unsupported transport: ${registration.transport.type}`)
}
```
### gRPC Handlers for MCP UI
The webview's MCP management UI calls these gRPC methods:
- `subscribeToMcpServers` — list servers with connection status
- `restartMcpServer` — disconnect + reconnect
- `deleteMcpServer` — unregister + delete from settings file
- `toggleMcpServer` — enable/disable
- `toggleToolAutoApprove` — per-tool auto-approve policy
- `updateMcpTimeout` — per-server timeout
- `authenticateMcpServer` — server-specific auth
Each handler translates the gRPC request to an MCP manager call
and returns the result in gRPC shape.
### MCP Marketplace
The marketplace fetches a catalog from the Cline API. For the initial
migration, we can:
- Read from disk cache (`~/.cline/data/cache/mcp_marketplace_catalog.json`)
- Implement `refreshMcpMarketplace` with authenticated API call
- Marketplace improvements are P1 and can follow later
## Settings CRUD Pattern
For reading/writing MCP server configurations, the SDK provides
`loadMcpSettingsFile()` but not a write function. Follow the pattern
used by the Tauri apps:
1. Read the file with `loadMcpSettingsFile()`
2. Modify the in-memory JSON
3. Write it back atomically (write-then-rename)
4. The file watcher picks up the change and reloads
## Tool Policies
The SDK provides MCP-specific disable policies:
```typescript
import { createDisabledMcpToolPolicies } from "@clinebot/core"
const policies = createDisabledMcpToolPolicies({
serverName: "risky-server",
toolNames: ["delete", "modify"],
})
// → { "risky-server__delete": { enabled: false }, ... }
```
For auto-approve, use `toolPolicies` in the session config:
```typescript
toolPolicies: {
"docs__search": { enabled: true, autoApprove: true },
"docs__write": { enabled: true, autoApprove: false },
}
+114
View File
@@ -0,0 +1,114 @@
# Cline SDK — Provider Credentials & OAuth Reference
How the SDK publishes provider metadata, handles credential resolution,
and orchestrates OAuth flows. For the migration plan, see [../README.md](../README.md).
## Provider Catalog
The SDK owns the canonical list of inference providers via `BUILTIN_SPECS`
in `@clinebot/llms`. Each provider is a `BuiltinSpec` with `id`, `name`,
`family`, `capabilities`, `apiKeyEnv`, `defaultModelId`, etc.
At runtime, `toManifest()` converts these to `GatewayProviderManifest`
objects that clients receive.
## Credential Resolution
Order: explicit `apiKey``apiKeyResolver()``apiKeyEnv` env vars.
If all fail, `getMissingApiKeyError()` produces a message naming the
expected env vars (e.g., `ANTHROPIC_API_KEY`).
## OAuth Authentication
### Providers Supporting OAuth
| Provider | Implementation |
|----------|---------------|
| `cline` | `packages/core/src/auth/cline.ts` |
| `openai-codex` | `packages/core/src/auth/codex.ts` (PKCE) |
| `oca` | `packages/core/src/auth/oca.ts` (PKCE) |
### Responsibility Split
| Concern | Owner |
|---------|-------|
| Spawn local callback server | **SDK** (`startLocalOAuthServer()`) |
| Build authorization URL | **SDK** |
| Open browser / present URL | **Client** (via `callbacks.onAuth()`) |
| Collect redirect code | **SDK** (local HTTP server) |
| Exchange code for tokens | **SDK** |
| Persist tokens | **Client** (adapter layer) |
### The SDK Does NOT Open Browsers
Uses callback-based interface:
```typescript
interface OAuthLoginCallbacks {
onAuth: (info: { url: string; instructions?: string }) => void
onPrompt: (prompt: OAuthPrompt) => Promise<string>
onProgress?: (message: string) => void
onManualCodeInput?: () => Promise<string>
}
```
### Client Integration Helper
```typescript
import { createOAuthClientCallbacks } from "@clinebot/core"
const callbacks = createOAuthClientCallbacks({
onPrompt: ...,
openUrl: (url) => vscode.env.openExternal(vscode.Uri.parse(url)),
})
```
### End-to-End Flow
```
1. Client calls SDK login function (e.g. loginClineOAuth)
2. SDK → startLocalOAuthServer() → binds to 127.0.0.1:{port}
3. SDK → builds authorization URL with redirect_uri = callback URL
4. SDK → callbacks.onAuth({ url, instructions })
5. Client → opens browser
6. User → authenticates in browser
7. Provider → redirects to callback URL with code
8. SDK → captures code, exchanges for tokens
9. SDK → returns OAuthCredentials { access, refresh, expires, accountId?, email? }
10. Client → persists tokens to secrets.json
```
### Provider-Specific Details
**Cline OAuth:**
- Authorization: `{apiBaseUrl}/auth/authorize?client_type=extension&callback_url=...`
- Token: `{apiBaseUrl}/auth/token`
- Default API base: `https://api.cline.bot`
- **Always use `{apiBaseUrl}`, never hardcode**
**OpenAI Codex OAuth:**
- Uses PKCE
- Fixed redirect: `http://localhost:1455/auth/callback`
- Client ID: `app_EMoamEEZ73f0CkXaXp7hrann`
**OCA OAuth:**
- Uses PKCE (S256)
- Supports `internal` and `external` modes
### Pitfalls From Previous Attempts
1. **`workos:` prefix**: Account IDs from the SDK may or may not have
a `workos:` prefix. The webview expects a specific format. Use
explicit conversion with tests.
2. **`{appBaseUrl}` vs hardcoded URLs**: Always use the environment
variable. Hardcoding `app.cline.bot` breaks the local/staging/
production switcher.
3. **Race condition on subscribe**: The webview may subscribe to
`subscribeToAuthStatusUpdate` before the bridge pushes initial
state. Always push initial state on subscribe.
4. **Token field names**: The SDK's `OAuthCredentials` uses `access`
and `refresh`, but classic storage may use different field names.
Map explicitly, don't rely on shape compatibility.
@@ -1,11 +1,11 @@
import { EmptyRequest, String } from "@shared/proto/cline/common"
import { AuthService } from "@/services/auth/AuthService"
import { AuthService } from "@/sdk/auth-service"
import { Controller } from "../index"
/**
* Handles the user clicking the login link in the UI.
* Generates a secure nonce for state validation, stores it in secrets,
* and opens the authentication URL in the external browser.
* Uses the SDK-backed AuthService to initiate the Cline OAuth flow.
* The SDK spawns a local callback server and opens the browser.
*
* @param controller The controller instance.
* @returns The login URL as a string.
@@ -1,17 +1,15 @@
import type { EmptyRequest } from "@shared/proto/cline/common"
import { Empty } from "@shared/proto/cline/common"
import { AuthService } from "@/services/auth/AuthService"
import { LogoutReason } from "@/services/auth/types"
import type { Controller } from "../index"
/**
* Handles the account logout action
* Handles the account logout action.
* Delegates to the SdkController which uses the SDK-backed AuthService.
* @param controller The controller instance
* @param _request The empty request object
* @returns Empty response
*/
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
await controller.handleSignOut()
await AuthService.getInstance().handleDeauth(LogoutReason.USER_INITIATED)
return Empty.create({})
}
@@ -32,7 +32,7 @@ export async function getOrganizationCredits(
balance: balanceData ? { currentBalance: balanceData.balance / 100 } : { currentBalance: 0 },
organizationId: balanceData?.organizationId || "",
usageTransactions:
usageTransactions?.map((tx) =>
usageTransactions?.map((tx: any) =>
OrganizationUsageTransaction.create({
aiInferenceProviderName: tx.aiInferenceProviderName,
aiModelName: tx.aiModelName,
@@ -19,7 +19,7 @@ export async function getUserOrganizations(controller: Controller, _request: Emp
return UserOrganizationsResponse.create({
organizations:
organizations?.map((org) =>
organizations?.map((org: any) =>
UserOrganization.create({
active: org.active,
memberId: org.memberId,
@@ -1,27 +1,22 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { ShowMessageType } from "@shared/proto/host/window"
import { HostProvider } from "@/hosts/host-provider"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { AuthService } from "@/sdk/auth-service"
import { Logger } from "@/shared/services/Logger"
import { openExternal } from "@/utils/env"
import { Controller } from ".."
/**
* Initiates OpenAI Codex OAuth authentication flow
* Opens the authorization URL in the user's browser
* Initiates OpenAI Codex OAuth authentication flow.
* Uses the SDK-backed AuthService which delegates to @clinebot/core's
* loginOpenAICodex() function.
*/
export async function openAiCodexSignIn(controller: Controller, _: EmptyRequest): Promise<Empty> {
try {
// Start the authorization flow and get the auth URL
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
const authService = AuthService.getInstance()
// Open the auth URL in the browser
await openExternal(authUrl)
// Wait for the OAuth callback in the background
// The callback will save credentials when complete
openAiCodexOAuthManager
.waitForCallback()
// Start the OAuth flow in the background
authService
.openAiCodexLogin()
.then(async () => {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
@@ -30,9 +25,7 @@ export async function openAiCodexSignIn(controller: Controller, _: EmptyRequest)
await controller.postStateToWebview()
})
.catch((error) => {
Logger.error("[openAiCodexSignIn] OAuth callback failed:", error)
openAiCodexOAuthManager.cancelAuthorizationFlow()
// Don't show notification for timeouts (user likely just abandoned)
Logger.error("[openAiCodexSignIn] OAuth flow failed:", error)
const errorMessage = error instanceof Error ? error.message : String(error)
if (!errorMessage.includes("timed out")) {
HostProvider.window.showMessage({
@@ -43,7 +36,6 @@ export async function openAiCodexSignIn(controller: Controller, _: EmptyRequest)
})
} catch (error) {
Logger.error("[openAiCodexSignIn] Failed to start OAuth flow:", error)
openAiCodexOAuthManager.cancelAuthorizationFlow()
throw error
}
@@ -1,18 +1,16 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { AuthService } from "@/sdk/auth-service"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Signs out of OpenAI Codex by clearing stored credentials
* Signs out of OpenAI Codex by clearing stored credentials.
* Uses the SDK-backed AuthService to clear provider settings.
*/
export async function openAiCodexSignOut(controller: Controller, _: EmptyRequest): Promise<Empty> {
try {
// Clear stored credentials
await openAiCodexOAuthManager.clearCredentials()
// Cancel any pending authorization flow
openAiCodexOAuthManager.cancelAuthorizationFlow()
// Clear stored credentials via SDK-backed AuthService
await AuthService.getInstance().clearCodexCredentials()
// Update the state to reflect sign out
await controller.postStateToWebview()
@@ -1,4 +1,4 @@
import { AuthService } from "@services/auth/AuthService"
import { AuthService } from "@/sdk/auth-service"
import { AuthState, EmptyRequest } from "@/shared/proto/index.cline"
import { Controller } from ".."
import { StreamingResponseHandler } from "../grpc-handler"
@@ -25,7 +25,7 @@ export async function openFocusChainFile(controller: Controller, request: String
const lastProgressMessage = clineMessages
.slice()
.reverse()
.find((m) => m.say === "task_progress")
.find((m: any) => m.say === "task_progress")
if (lastProgressMessage && lastProgressMessage.text) {
initialFocusChainContent = extractFocusChainListFromText(lastProgressMessage.text) || undefined
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
// Extracted from classic src/core/controller/index.ts (see origin/main)
//
// Standalone function to build ExtensionState from a Controller instance.
// This allows the SdkController to reuse the classic state-building logic
// without inheriting the entire classic Controller implementation.
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
import { ClineEnv } from "@/config"
import { ExtensionRegistryInfo } from "@/registry"
import { BannerService } from "@/services/banner/BannerService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { getLatestAnnouncementId } from "@/utils/announcements"
import { getClineOnboardingModels } from "../models/getClineOnboardingModels"
/**
* Builds the ExtensionState object to push to the webview.
* Extracted from the classic Controller.getStateToPostToWebview().
*/
export async function getStateToPostToWebview(controller: {
task?: any
stateManager: any
mcpHub?: any
backgroundCommandRunning?: boolean
backgroundCommandTaskId?: string
workspaceManager?: any
}): Promise<ExtensionState> {
const stateManager = controller.stateManager
// Get API configuration from cache for immediate access
const onboardingModels = getClineOnboardingModels()
const apiConfiguration = stateManager.getApiConfiguration()
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
const taskHistory = stateManager.getGlobalStateKey("taskHistory")
const autoApprovalSettings = stateManager.getGlobalSettingsKey("autoApprovalSettings")
const browserSettings = stateManager.getGlobalSettingsKey("browserSettings")
const focusChainSettings = stateManager.getGlobalSettingsKey("focusChainSettings")
const preferredLanguage = stateManager.getGlobalSettingsKey("preferredLanguage")
const mode = stateManager.getGlobalSettingsKey("mode")
const strictPlanModeEnabled = stateManager.getGlobalSettingsKey("strictPlanModeEnabled")
const yoloModeToggled = stateManager.getGlobalSettingsKey("yoloModeToggled")
const useAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense")
const subagentsEnabled = stateManager.getGlobalSettingsKey("subagentsEnabled")
const userInfo = stateManager.getGlobalStateKey("userInfo")
const mcpMarketplaceEnabled = stateManager.getGlobalStateKey("mcpMarketplaceEnabled")
const mcpDisplayMode = stateManager.getGlobalStateKey("mcpDisplayMode")
const telemetrySetting = stateManager.getGlobalSettingsKey("telemetrySetting")
const planActSeparateModelsSetting = stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
const enableCheckpointsSetting = stateManager.getGlobalSettingsKey("enableCheckpointsSetting")
const globalClineRulesToggles = stateManager.getGlobalStateKey("globalClineRulesToggles")
const globalWorkflowToggles = stateManager.getGlobalStateKey("globalWorkflowToggles")
const globalSkillsToggles = stateManager.getGlobalStateKey("globalSkillsToggles")
const localSkillsToggles = stateManager.getWorkspaceStateKey("localSkillsToggles")
const remoteRulesToggles = stateManager.getGlobalStateKey("remoteRulesToggles")
const remoteWorkflowToggles = stateManager.getGlobalStateKey("remoteWorkflowToggles")
const shellIntegrationTimeout = stateManager.getGlobalSettingsKey("shellIntegrationTimeout")
const terminalReuseEnabled = stateManager.getGlobalStateKey("terminalReuseEnabled")
const vscodeTerminalExecutionMode = stateManager.getGlobalStateKey("vscodeTerminalExecutionMode")
const defaultTerminalProfile = stateManager.getGlobalSettingsKey("defaultTerminalProfile")
const isNewUser = stateManager.getGlobalStateKey("isNewUser")
const welcomeViewCompleted = !!stateManager.getGlobalStateKey("welcomeViewCompleted")
const customPrompt = stateManager.getGlobalSettingsKey("customPrompt")
const mcpResponsesCollapsed = stateManager.getGlobalStateKey("mcpResponsesCollapsed")
const terminalOutputLineLimit = stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
const maxConsecutiveMistakes = stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")
const favoritedModelIds = stateManager.getGlobalStateKey("favoritedModelIds")
const lastDismissedInfoBannerVersion = stateManager.getGlobalStateKey("lastDismissedInfoBannerVersion") || 0
const lastDismissedModelBannerVersion = stateManager.getGlobalStateKey("lastDismissedModelBannerVersion") || 0
const lastDismissedCliBannerVersion = stateManager.getGlobalStateKey("lastDismissedCliBannerVersion") || 0
const dismissedBanners = stateManager.getGlobalStateKey("dismissedBanners")
const doubleCheckCompletionEnabled = stateManager.getGlobalSettingsKey("doubleCheckCompletionEnabled")
const lazyTeammateModeEnabled = stateManager.getGlobalSettingsKey("lazyTeammateModeEnabled")
const showFeatureTips = stateManager.getGlobalSettingsKey("showFeatureTips")
const localClineRulesToggles = stateManager.getWorkspaceStateKey("localClineRulesToggles")
const localWindsurfRulesToggles = stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
const localCursorRulesToggles = stateManager.getWorkspaceStateKey("localCursorRulesToggles")
const localAgentsRulesToggles = stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
const workflowToggles = stateManager.getWorkspaceStateKey("workflowToggles")
const currentTaskItem = controller.task?.taskId
? (taskHistory || []).find((item: any) => item.id === controller.task?.taskId)
: undefined
const clineMessages = [...(controller.task?.messageStateHandler?.getClineMessages?.() || [])]
const checkpointManagerErrorMessage = controller.task?.taskState?.checkpointManagerErrorMessage
const processedTaskHistory = (taskHistory || [])
.filter((item: any) => item.ts && item.task)
.sort((a: any, b: any) => b.ts - a.ts)
.slice(0, 100)
const latestAnnouncementId = getLatestAnnouncementId()
const shouldShowAnnouncement = lastShownAnnouncementId !== latestAnnouncementId
const platform = process.platform as Platform
const distinctId = getDistinctId()
const version = ExtensionRegistryInfo.version
const clineConfig = ClineEnv.config()
const environment = clineConfig.environment
const banners = BannerService.get().getActiveBanners() ?? []
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
// Check OpenAI Codex authentication status
let openAiCodexIsAuthenticated = false
try {
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
openAiCodexIsAuthenticated = await openAiCodexOAuthManager.isAuthenticated()
} catch {
// Codex OAuth not available
}
return {
version,
apiConfiguration,
currentTaskItem,
clineMessages,
currentFocusChainChecklist: controller.task?.taskState?.currentFocusChainChecklist || null,
checkpointManagerErrorMessage,
autoApprovalSettings,
browserSettings,
focusChainSettings,
preferredLanguage,
mode,
strictPlanModeEnabled,
yoloModeToggled,
useAutoCondense,
subagentsEnabled,
userInfo,
mcpMarketplaceEnabled,
mcpDisplayMode,
telemetrySetting,
planActSeparateModelsSetting,
enableCheckpointsSetting: enableCheckpointsSetting ?? true,
platform,
environment,
distinctId,
globalClineRulesToggles: globalClineRulesToggles || {},
localClineRulesToggles: localClineRulesToggles || {},
localWindsurfRulesToggles: localWindsurfRulesToggles || {},
localCursorRulesToggles: localCursorRulesToggles || {},
localAgentsRulesToggles: localAgentsRulesToggles || {},
localWorkflowToggles: workflowToggles || {},
globalWorkflowToggles: globalWorkflowToggles || {},
globalSkillsToggles: globalSkillsToggles || {},
localSkillsToggles: localSkillsToggles || {},
remoteRulesToggles,
remoteWorkflowToggles,
shellIntegrationTimeout,
terminalReuseEnabled,
vscodeTerminalExecutionMode,
defaultTerminalProfile,
isNewUser,
welcomeViewCompleted,
onboardingModels,
mcpResponsesCollapsed,
terminalOutputLineLimit,
maxConsecutiveMistakes,
customPrompt,
taskHistory: processedTaskHistory,
shouldShowAnnouncement,
favoritedModelIds,
backgroundCommandRunning: controller.backgroundCommandRunning ?? false,
backgroundCommandTaskId: controller.backgroundCommandTaskId,
workspaceRoots: controller.workspaceManager?.getRoots?.() ?? [],
primaryRootIndex: controller.workspaceManager?.getPrimaryIndex?.() ?? 0,
isMultiRootWorkspace: (controller.workspaceManager?.getRoots?.()?.length ?? 0) > 1,
multiRootSetting: {
user: stateManager.getGlobalStateKey("multiRootEnabled"),
featureFlag: true,
},
clineWebToolsEnabled: {
user: stateManager.getGlobalSettingsKey("clineWebToolsEnabled"),
featureFlag: featureFlagsService.getWebtoolsEnabled(),
},
worktreesEnabled: {
user: stateManager.getGlobalSettingsKey("worktreesEnabled"),
featureFlag: featureFlagsService.getWorktreesEnabled(),
},
hooksEnabled: getHooksEnabledSafe(stateManager.getGlobalSettingsKey("hooksEnabled")),
lastDismissedInfoBannerVersion,
lastDismissedModelBannerVersion,
remoteConfigSettings: stateManager.getRemoteConfigSettings?.(),
lastDismissedCliBannerVersion,
dismissedBanners,
nativeToolCallSetting: stateManager.getGlobalStateKey("nativeToolCallEnabled"),
enableParallelToolCalling: stateManager.getGlobalSettingsKey("enableParallelToolCalling"),
backgroundEditEnabled: stateManager.getGlobalSettingsKey("backgroundEditEnabled"),
optOutOfRemoteConfig: stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
doubleCheckCompletionEnabled,
lazyTeammateModeEnabled,
showFeatureTips,
banners,
welcomeBanners,
openAiCodexIsAuthenticated,
} as ExtensionState
}
+1 -1
View File
@@ -24,7 +24,7 @@ import { accountLogoutClicked } from "../account/accountLogoutClicked"
*/
export async function updateSettings(controller: Controller, request: UpdateSettingsRequest): Promise<Empty> {
try {
if (request.clineEnv !== undefined) {
if (request.clineEnv !== undefined && request.clineEnv !== "") {
ClineEnv.setEnvironment(request.clineEnv)
await accountLogoutClicked(controller, Empty.create())
}
+25 -37
View File
@@ -48,54 +48,42 @@ export async function deleteTasksWithIds(controller: Controller, request: String
* @param id The task ID to delete
*/
async function deleteTaskWithId(controller: Controller, id: string): Promise<void> {
// Clear current task if it matches the ID being deleted
if (id === controller.task?.taskId) {
await controller.clearTask()
Logger.debug("cleared task")
}
// Remove task from state FIRST — this updates the in-memory cache
// immediately so the next postStateToWebview() sends the updated list.
const updatedTaskHistory = await controller.deleteTaskFromState(id)
// Try to clean up task files on disk (best-effort — don't let file
// errors prevent the UI from updating).
try {
// Clear current task if it matches the ID being deleted
if (id === controller.task?.taskId) {
await controller.clearTask()
Logger.debug("cleared task")
}
const taskDirPath = path.join(HostProvider.get().globalStorageFsPath, "tasks", id)
await fs.rm(taskDirPath, { recursive: true, force: true })
} catch (error) {
Logger.debug(`Error cleaning up task files for ${id}:`, error)
}
// Get task file paths
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath, contextHistoryFilePath, taskMetadataFilePath } =
await controller.getTaskWithId(id)
// Remove task from state
const updatedTaskHistory = await controller.deleteTaskFromState(id)
// Delete the task files
for (const filePath of [
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
]) {
await fs.rm(filePath, { force: true })
}
// Remove empty task directory
// If no tasks remain, clean up the top-level directories
if (updatedTaskHistory.length === 0) {
try {
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
} catch (error) {
Logger.debug("Could not remove task directory (may not be empty):", error)
}
// If no tasks remain, clean up everything
if (updatedTaskHistory.length === 0) {
const taskDirPath = path.join(HostProvider.get().globalStorageFsPath, "tasks")
const tasksDirPath = path.join(HostProvider.get().globalStorageFsPath, "tasks")
const checkpointsDirPath = path.join(HostProvider.get().globalStorageFsPath, "checkpoints")
if (await fileExistsAtPath(taskDirPath)) {
await fs.rm(taskDirPath, { recursive: true, force: true })
if (await fileExistsAtPath(tasksDirPath)) {
await fs.rm(tasksDirPath, { recursive: true, force: true })
}
if (await fileExistsAtPath(checkpointsDirPath)) {
await fs.rm(checkpointsDirPath, { recursive: true, force: true })
}
} catch (error) {
Logger.debug("Error cleaning up empty task/checkpoint directories:", error)
}
} catch (error) {
Logger.debug(`Error deleting task ${id}:`, error)
throw error // Re-throw to let caller handle the error
}
// Update webview state
// Always update webview state so the history list and recents refresh
await controller.postStateToWebview()
}
+40 -38
View File
@@ -5,62 +5,64 @@ import { Controller } from ".."
import { sendChatButtonClickedEvent } from "../ui/subscribeToChatButtonClicked"
/**
* Shows a task with the specified ID
* @param controller The controller instance
* @param request The request containing the task ID
* @returns TaskResponse with task details
* Shows a task with the specified ID by loading its messages from disk.
*
* This does NOT start a new session or inference — it just loads the task
* for viewing. The SdkController.showTaskWithId() method handles:
* 1. Looking up the history item
* 2. Tearing down any active session
* 3. Creating a task proxy with loaded messages
* 4. Pushing messages through both state updates and partial message stream
* 5. Posting state to the webview
*
* Previously this handler called controller.initTask() which started a NEW
* session instead of loading the existing task's messages (S6-27 bug).
*/
export async function showTaskWithId(controller: Controller, request: StringRequest): Promise<TaskResponse> {
try {
const id = request.value
// First check if task exists in global state for faster access
// Look up the history item for the gRPC response
const taskHistory = controller.stateManager.getGlobalStateKey("taskHistory")
const historyItem = taskHistory.find((item) => item.id === id)
// We need to initialize the task before returning data
if (historyItem) {
// Always initialize the task with the history item
await controller.initTask(undefined, undefined, undefined, historyItem)
// Send UI update to show the chat view
if (!historyItem) {
// If not in global state, try fetching from storage
const { historyItem: fetchedItem } = await controller.getTaskWithId(id)
await controller.showTaskWithId(id)
await sendChatButtonClickedEvent()
// Return task data for gRPC response
return TaskResponse.create({
id: historyItem.id,
task: historyItem.task || "",
ts: historyItem.ts || 0,
isFavorited: historyItem.isFavorited || false,
size: historyItem.size || 0,
totalCost: historyItem.totalCost || 0,
tokensIn: historyItem.tokensIn || 0,
tokensOut: historyItem.tokensOut || 0,
cacheWrites: historyItem.cacheWrites || 0,
cacheReads: historyItem.cacheReads || 0,
id: fetchedItem.id,
task: fetchedItem.task || "",
ts: fetchedItem.ts || 0,
isFavorited: fetchedItem.isFavorited || false,
size: fetchedItem.size || 0,
totalCost: fetchedItem.totalCost || 0,
tokensIn: fetchedItem.tokensIn || 0,
tokensOut: fetchedItem.tokensOut || 0,
cacheWrites: fetchedItem.cacheWrites || 0,
cacheReads: fetchedItem.cacheReads || 0,
})
}
// If not in global state, fetch from storage
const { historyItem: fetchedItem } = await controller.getTaskWithId(id)
// Initialize the task with the fetched item
await controller.initTask(undefined, undefined, undefined, fetchedItem)
// Load the task's messages from disk (no new session, no inference)
await controller.showTaskWithId(id)
// Send UI update to show the chat view
await sendChatButtonClickedEvent()
// Return task data for gRPC response
return TaskResponse.create({
id: fetchedItem.id,
task: fetchedItem.task || "",
ts: fetchedItem.ts || 0,
isFavorited: fetchedItem.isFavorited || false,
size: fetchedItem.size || 0,
totalCost: fetchedItem.totalCost || 0,
tokensIn: fetchedItem.tokensIn || 0,
tokensOut: fetchedItem.tokensOut || 0,
cacheWrites: fetchedItem.cacheWrites || 0,
cacheReads: fetchedItem.cacheReads || 0,
id: historyItem.id,
task: historyItem.task || "",
ts: historyItem.ts || 0,
isFavorited: historyItem.isFavorited || false,
size: historyItem.size || 0,
totalCost: historyItem.totalCost || 0,
tokensIn: historyItem.tokensIn || 0,
tokensOut: historyItem.tokensOut || 0,
cacheWrites: historyItem.cacheWrites || 0,
cacheReads: historyItem.cacheReads || 0,
})
} catch (error) {
Logger.error("Error in showTaskWithId:", error)
+309
View File
@@ -0,0 +1,309 @@
# Debug Harness
An HTTP-controlled debug server for the Cline VSCode extension. Provides
programmatic access to:
- **Extension host debugging** (Node.js): breakpoints, evaluate, step, pause/resume via CDP
- **Webview debugging** (Chrome): breakpoints, evaluate via CDP
- **UI automation**: click, type, screenshot, open sidebar via Playwright
- **Sourcemap resolution**: set breakpoints by original source file + line
Designed to be driven from an agentic loop via `curl` commands.
## Quick Start
```bash
# Terminal 1: Start the debug harness server
npx tsx src/dev/debug-harness/server.ts --auto-launch --skip-build
# Terminal 2: Interact via curl
curl localhost:19229/api -d '{"method":"status"}'
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
```
## Server Options
```
npx tsx src/dev/debug-harness/server.ts [options]
Options:
--skip-build Skip building extension/webview (use existing dist/)
--auto-launch Automatically launch VSCode on startup
--workspace PATH Workspace directory to open (default: /tmp/cline-debug-workspace)
--port PORT Server port (default: 19229)
```
## Full Build + Launch (first time)
```bash
# This builds protos, extension (unminified+sourcemaps), webview (unminified+sourcemaps),
# downloads VSCode, launches it, and connects CDP to the extension host.
npx tsx src/dev/debug-harness/server.ts --auto-launch
```
## API
All commands are sent as `POST /api` with JSON body `{"method": "...", "params": {...}}`.
Responses: `{"result": {...}}` on success, `{"error": "..."}` on failure.
Convenience endpoints:
- `GET /health``{"status": "ok"}`
- `GET /status` — Full harness status
### Lifecycle
| Method | Params | Description |
|--------|--------|-------------|
| `launch` | `{workspace?, skipBuild?}` | Build + launch VSCode |
| `shutdown` | | Close VSCode and CDP connections |
| `status` | | Current state of all components |
| `connect_webview` | | Connect CDP to the webview (call after sidebar is open) |
### Extension Host Debugging (Node.js)
| Method | Params | Description |
|--------|--------|-------------|
| `ext.set_breakpoint` | `{file, line, column?, condition?}` | Set breakpoint by source file (sourcemap-resolved) |
| `ext.set_breakpoint_raw` | `{url?, urlRegex?, scriptId?, lineNumber, columnNumber?, condition?}` | Set breakpoint with raw CDP params |
| `ext.remove_breakpoint` | `{breakpointId}` | Remove a breakpoint |
| `ext.evaluate` | `{expression, callFrameId?}` | Evaluate expression (at breakpoint or global) |
| `ext.pause` | | Pause execution |
| `ext.resume` | | Resume execution |
| `ext.step_over` | | Step over |
| `ext.step_into` | | Step into |
| `ext.step_out` | | Step out |
| `ext.call_stack` | | Get call stack (when paused) |
| `ext.scripts` | `{filter?}` | List loaded scripts |
| `ext.source_files` | | List source files from sourcemap |
| `ext.get_properties` | `{objectId}` | Get object properties |
| `ext.get_script_source` | `{scriptId}` | Get script source text |
### Webview Debugging (Chrome)
Call `connect_webview` first after the sidebar is open (only needed for breakpoints/stepping).
| Method | Params | Description |
|--------|--------|-------------|
| `web.set_breakpoint` | `{url, line, column?, condition?}` | Set breakpoint by URL pattern |
| `web.remove_breakpoint` | `{breakpointId}` | Remove a breakpoint |
| `web.evaluate` | `{expression, callFrameId?}` | Evaluate in sidebar (Playwright) or at breakpoint (CDP) |
| `web.post_message` | `{message}` | Send a postMessage to the extension host via exposed vsCodeApi |
| `web.pause` | | Pause |
| `web.resume` | | Resume |
| `web.step_over/into/out` | | Stepping |
### UI Automation (Playwright)
| Method | Params | Description |
|--------|--------|-------------|
| `ui.screenshot` | `{fullPage?}` | Take screenshot → returns `{path}` (use `read_file` on the path, don't `open` the file) |
| `ui.sidebar_screenshot` | | Screenshot focused on sidebar → returns `{path}` |
| `ui.click` | `{selector, frame?, delay?}` | Click element (`frame: "sidebar"` for webview) |
| `ui.fill` | `{selector, text, frame?}` | Fill input |
| `ui.press` | `{key}` | Press key (e.g., "Enter", "Meta+Shift+p") |
| `ui.type` | `{text, delay?}` | Type text |
| `ui.open_sidebar` | | Open the Cline sidebar |
| `ui.frames` | | List all frames |
| `ui.wait_for_selector` | `{selector, frame?, timeout?}` | Wait for element |
| `ui.command_palette` | `{command}` | Open command palette and run command |
| `ui.get_text` | `{selector, frame?}` | Get element text |
| `ui.locator` | `{role?, name?, testId?, text?, frame?, action?, value?}` | Rich Playwright locator (auto-retries with frame refresh for sidebar) |
| `ui.react_input` | `{text, selector?, clear?, submit?}` | Set React-controlled textarea value via `execCommand('insertText')` |
| `ui.send_message` | `{text, images?, files?, responseType?}` | Send a chat message bypassing the textarea (via gRPC postMessage) |
### Combined
| Method | Params | Description |
|--------|--------|-------------|
| `wait_for_pause` | `{timeout?}` | Block until any debuggee hits a breakpoint |
## Example Workflows
### 1. Set a breakpoint and observe execution
```bash
# Set breakpoint in the extension's activate function
curl localhost:19229/api -d '{
"method": "ext.set_breakpoint",
"params": {"file": "src/extension.ts", "line": 25}
}'
# Trigger the breakpoint by opening the sidebar
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
# Wait for the breakpoint to hit
curl localhost:19229/api -d '{"method": "wait_for_pause", "params": {"timeout": 10000}}'
# Examine the call stack
curl localhost:19229/api -d '{"method": "ext.call_stack"}'
# Evaluate a local variable
curl localhost:19229/api -d '{
"method": "ext.evaluate",
"params": {"expression": "context.extensionPath", "callFrameId": "<from call_stack>"}
}'
# Step over
curl localhost:19229/api -d '{"method": "ext.step_over"}'
# Continue
curl localhost:19229/api -d '{"method": "ext.resume"}'
```
### 2. Conditional breakpoint
```bash
curl localhost:19229/api -d '{
"method": "ext.set_breakpoint",
"params": {
"file": "src/core/controller/index.ts",
"line": 100,
"condition": "message.type === \"newTask\""
}
}'
```
### 3. Interact with the webview
```bash
# Open sidebar
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
# Type in the chat input
curl localhost:19229/api -d '{
"method": "ui.locator",
"params": {"testId": "chat-input", "frame": "sidebar", "action": "fill", "value": "Hello!"}
}'
# Click send
curl localhost:19229/api -d '{
"method": "ui.locator",
"params": {"testId": "send-button", "frame": "sidebar", "action": "click"}
}'
# Take a screenshot
curl localhost:19229/api -d '{"method": "ui.screenshot"}'
# Returns: {"result": {"path": "/tmp/cline-debug/screenshot-0001.png"}}
```
### 4. Evaluate in the webview
```bash
curl localhost:19229/api -d '{
"method": "web.evaluate",
"params": {"expression": "document.title"}
}'
```
### 5. Reliable textarea input (React-compatible)
```bash
# Use execCommand-based input that works reliably across multiple tasks
curl localhost:19229/api -d '{
"method": "ui.react_input",
"params": {"text": "Hello from debug harness!", "submit": true}
}'
# Or set text without submitting
curl localhost:19229/api -d '{
"method": "ui.react_input",
"params": {"text": "Draft message", "clear": true}
}'
```
### 6. Send a message bypassing the textarea entirely
```bash
# Start a new task (no active conversation)
curl localhost:19229/api -d '{
"method": "ui.send_message",
"params": {"text": "Say hello world"}
}'
# Respond to a followup/resume prompt
curl localhost:19229/api -d '{
"method": "ui.send_message",
"params": {"text": "Yes, continue", "responseType": "yesButtonClicked"}
}'
```
### 7. Send postMessage to extension host
```bash
# Send an arbitrary message to the extension via the webview's VS Code API
curl localhost:19229/api -d '{
"method": "web.post_message",
"params": {"message": {"type": "grpc_request", "service": "cline.TaskService", "method": "clearTask", "requestId": "debug-1", "payload": {}}}
}'
```
### 8. Find the right script for breakpoints
```bash
# List scripts containing "extension"
curl localhost:19229/api -d '{
"method": "ext.scripts",
"params": {"filter": "extension"}
}'
# List all original source files from the sourcemap
curl localhost:19229/api -d '{"method": "ext.source_files"}'
```
## How It Works
1. **Build**: esbuild bundles `src/extension.ts``dist/extension.js` (unminified, with
sourcemaps). Vite builds `webview-ui/``webview-ui/build/` (unminified, inline sourcemaps).
2. **Launch**: Uses `@vscode/test-electron` to download VSCode, then Playwright's
`_electron.launch()` to start it with `--inspect-extensions=9230` for Node.js inspector
access and `--extensionDevelopmentPath` to load our extension.
3. **Extension CDP**: Connects to the extension host's V8 inspector via WebSocket on port 9230.
Enables `Debugger` and `Runtime` domains. Tracks `scriptParsed` events and `paused`/`resumed`
state.
4. **Sourcemap Resolution**: When setting breakpoints by source file, reads `dist/extension.js.map`
and resolves the original file + line to the generated (bundled) file + line using VLQ-decoded
sourcemap mappings.
5. **Webview CDP**: After the sidebar loads, creates a Playwright CDP session for the webview
frame, enabling debugger commands. Falls back to `frame.evaluate()` for expression evaluation.
6. **UI Automation**: Playwright's Page/Frame APIs provide click, fill, type, screenshot, locator
queries, and more. The sidebar webview is accessed as a Frame within the VSCode window.
## Caveats
**⚠️ "Introducing Cline Kanban" overlay**: On fresh launches, a full-screen promo overlay may
appear in the sidebar. It blocks all interactions and makes screenshots useless. **Dismiss it
immediately after opening the sidebar**, before doing anything else:
```bash
# Most reliable — click the close button via DOM:
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
curl localhost:19229/api -d '{
"method": "web.evaluate",
"params": {"expression": "document.querySelector(\".sr-only\")?.parentElement?.click()"}
}'
```
**Screenshots**: `ui.screenshot` and `ui.sidebar_screenshot` save PNG files to `/tmp/cline-debug/`
and return `{path}` in the response. **Do NOT `open` the file** — on macOS this launches Preview.app
which covers the VSCode window. Use `read_file` on the returned path to examine the image.
## Troubleshooting
**"Inspector not available on port 9230"**: The extension host hasn't started yet. Wait longer
or check that the extension built correctly.
**"Sidebar frame not found"**: The Cline sidebar isn't open. Use `ui.open_sidebar` first.
**"Webview CDP not connected"**: Call `connect_webview` after the sidebar is open. If it fails,
webview breakpoints aren't available, but `web.evaluate` still works via Playwright.
**Sourcemap resolution fails**: Use `ext.source_files` to see what paths the sourcemap contains,
then use `ext.set_breakpoint_raw` with a `urlRegex` pattern.
**Screenshots directory**: Saved to `/tmp/cline-debug/` (configurable via SCREENSHOT_DIR).
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -50,8 +50,7 @@ import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { exportVSCodeStorageToSharedFiles } from "./hosts/vscode/vscode-to-file-migration"
import { ExtensionRegistryInfo } from "./registry"
import { AuthService } from "./services/auth/AuthService"
import { LogoutReason } from "./services/auth/types"
import { AuthService, LogoutReason } from "./sdk/auth-service"
import { telemetryService } from "./services/telemetry"
import { SharedUriHandler, TASK_URI_PATH } from "./services/uri/SharedUriHandler"
import { ShowMessageType } from "./shared/proto/host/window"
@@ -89,7 +88,8 @@ export async function activate(context: vscode.ExtensionContext) {
// Initialize hook discovery cache for performance optimization
HookDiscoveryCache.getInstance().initialize(
context as any, // Adapt VSCode ExtensionContext to generic interface
// biome-ignore lint/suspicious/noExplicitAny: Adapt VSCode ExtensionContext to generic interface
context as any,
(dir: string) => {
try {
const pattern = new vscode.RelativePattern(dir, "*")
@@ -191,7 +191,7 @@ export async function activate(context: vscode.ExtensionContext) {
Logger.log("[Cline Dev] Dev mode activated & dev commands registered")
})
.catch((error) => {
Logger.log("[Cline Dev] Failed to register dev commands: " + error)
Logger.log(`[Cline Dev] Failed to register dev commands: ${error}`)
})
}
@@ -523,7 +523,10 @@ ${ctx.cellJson || "{}"}
}),
)
// Listen for secrets changes (e.g., cross-window login/logout sync)
// Listen for secrets changes (cross-window login/logout sync).
// NOTE: Credentials now live in providers.json (single source of truth).
// This listener catches legacy secrets.json writes from older windows and
// triggers a re-read from providers.json via restoreRefreshTokenAndRetrieveAuthInfo().
const unsubSecrets = storageContext.secrets.onDidChange((event) => {
if (event.key === "cline:clineAccountId") {
const secretValue = storageContext.secrets.get<string>(event.key)
@@ -745,6 +748,6 @@ async function cleanupLegacyVSCodeStorage(context: ExtensionContext): Promise<vo
Logger.info("[VS Code Storage Migrations] Completed")
} catch (error) {
Logger.warn("[VS Code Storage Migrations] Failed" + (error instanceof Error ? `: ${error.message}` : ""))
Logger.warn(`[VS Code Storage Migrations] Failed${error instanceof Error ? `: ${error.message}` : ""}`)
}
}
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
# TODO: Handle session resumption in `askResponse`
When a user clicks on an old task (`showTaskWithId` sets `this.task` with loaded messages
but does NOT create an `activeSession`), then sends a follow-up message, `askResponse`
currently bails out with "No active session". We need to spin up a new SDK session that
carries the old conversation history so the model has full context.
## Key findings from SDK source
- `DefaultSessionManager.start()` accepts `config.sessionId` — if set, it reuses that ID
and looks up the persisted row (`resumedRow`). However, old tasks were created by the
classic controller, not the SDK, so there is no persisted SDK row to find.
- `StartSessionInput.initialMessages` is passed through to the agent as `agentConfig.initialMessages`.
The agent uses these as prior conversation context. This is the mechanism for resumption.
- `executeAgentTurn` checks `session.started || agent.getMessages().length > 0` to decide
whether to call `agent.continue()` (append to history) vs `agent.run()` (fresh). If
`initialMessages` are provided, `getMessages().length > 0` will be true, so it correctly
calls `agent.continue()`.
- The old conversation history lives in **three** places:
1. `ui_messages.json` — ClineMessage[] for the webview (already loaded by `showTaskWithId`)
2. `api_conversation_history.json` — Anthropic.MessageParam[] (classic controller only)
3. `~/.cline/data/sessions/<sessionId>/<sessionId>.messages.json` — SDK-persisted LLM
messages (SQLite-indexed, read via `sessionManager.readMessages(sessionId)`)
- For SDK-created tasks, only #1 and #3 exist. `api_conversation_history.json` is never
written by the SDK controller.
- The SDK's `LlmsProviders.Message[]` format is compatible with `Anthropic.MessageParam[]`
(both are `{role, content: string | ContentBlock[]}` structurally).
## Tasks
### 1. ✅ Load conversation history from SDK persistence (primary) + classic fallback
- **Problem discovered**: The original implementation only read from
`api_conversation_history.json`, which is NEVER written by the SDK controller.
SDK-created tasks persist messages via `DefaultSessionManager.executeAgentTurn()`
`persistSessionMessages()` to SQLite/file storage at
`~/.cline/data/sessions/<sessionId>/<sessionId>.messages.json`.
- **Fix**: Use `sessionManager.readMessages(taskId)` FIRST (reads from SQLite via
the session service). Fall back to `getSavedApiConversationHistory(taskId)` for
tasks created by the classic (non-SDK) controller.
- **IMPORTANT**: Must read BEFORE `start()` since `start()` with the same `sessionId`
overwrites the session row/manifest.
- **Implementation**: Creates `VscodeSessionHost` first, calls `readMessages(taskId)`,
then falls back to classic `api_conversation_history.json`.
### 2. ✅ Look up the HistoryItem for the old task
- Need the `HistoryItem` to get `cwdOnTaskInitialization` for the session config's `cwd`.
- Read from `StateManager.getGlobalStateKey("taskHistory")` (same as `showTaskWithId` does).
- **Implementation**: Reads from `this.stateManager.getGlobalStateKey("taskHistory")` and
falls back to `process.cwd()` if not found.
### 3. ✅ Build a new session config
- Call `buildSessionConfig()` with the old task's `cwd` and current mode/provider settings.
- Set `config.sessionId` to the old task ID so the new session reuses the same ID
(keeps history item linkage consistent).
- **Implementation**: Calls `buildSessionConfig({ cwd, mode })` then sets `config.sessionId = taskId`.
### 4. ✅ Create VscodeSessionHost and subscribe to events
- Same pattern as `initTask`: `VscodeSessionHost.create({ mcpHub })`, then `subscribe()`.
- **Implementation**: Creates `VscodeSessionHost`, subscribes with `handleSessionEvent`.
Done before reading messages so `readMessages()` can use the session manager.
### 5. ✅ Start the session with `initialMessages`
- Call `sessionManager.start()` with the loaded conversation history as `initialMessages`.
- Pass `interactive: true`, no `prompt` (same as `initTask` — fast return).
- This gives the agent the full conversation context from the old task.
- **Implementation**: Passes `initialMessages` when non-empty. Omits when empty (fresh session).
### 6. ✅ Wire up the activeSession
- Set `this.activeSession` with the new session ID, manager, unsubscribe fn, etc.
- Update `this.task.taskId` to the new session ID if it changed (though ideally keep it
the same by setting `config.sessionId`).
- **Implementation**: Sets `this.activeSession` and updates `this.task.taskId` if needed.
### 7. ✅ Send the user's follow-up message
- Call `sessionManager.send()` fire-and-forget with the user's prompt (same as current
`askResponse` logic).
- The agent will call `agent.continue()` since `initialMessages` were provided, appending
to the existing conversation.
- **Implementation**: Fire-and-forget `sessionManager.send()` with `.then()` / `.catch()`.
### 8. ✅ Update the HistoryItem
- Update the existing history item's timestamp and model info so it appears as recently active.
- **Implementation**: Sets `historyItem.ts = Date.now()` and `historyItem.modelId = config.modelId`,
then calls `this.updateTaskHistory(historyItem)`.
### 9. ✅ Emit the user's message to the webview
- Add a "say:user_feedback" ClineMessage for the user's follow-up text so the webview shows
it in the chat (the old messages are already loaded from `showTaskWithId`).
- **Implementation**: Creates a ClineMessage with `say: "user_feedback"`, adds to
`messageStateHandler`, emits via `emitSessionEvents`, and calls `postStateToWebview()`.
### 10. ✅ Handle edge cases
- If no conversation history exists at all, fall back to starting a fresh session with a
summary prompt (e.g., "[TASK RESUMPTION] Resuming task: ...").
- If no prompt is provided and we have history, session stays idle (ready for future messages).
- **Implementation**: `effectivePrompt` falls back to `[TASK RESUMPTION]` string when
no conversation history and no user prompt. If no effective prompt at all, session
is marked idle (`isRunning = false`).
## Implementation location
All changes in `src/sdk/SdkController.ts`:
- Modified `askResponse()` to detect `this.task && !this.activeSession` and call `resumeSessionFromTask()`
- Added private `resumeSessionFromTask(taskId, prompt, images, files)` method
## Bug fix applied
- **Root cause**: Original Task 1 read only from `api_conversation_history.json` (classic
controller format), which is never written by the SDK controller. SDK-created tasks
store LLM messages in SQLite at `~/.cline/data/sessions/`.
- **Fix**: Read from `sessionManager.readMessages(taskId)` first (SDK persistence),
fall back to `getSavedApiConversationHistory(taskId)` (classic persistence).
+258
View File
@@ -0,0 +1,258 @@
// Replaces classic src/services/account/ClineAccountService.ts (see origin/main)
//
// SDK-backed account service. Handles credits, organizations, and user data
// by making authenticated requests to the Cline API.
import type {
BalanceResponse,
OrganizationBalanceResponse,
OrganizationUsageTransaction,
PaymentTransaction,
UsageTransaction,
UserResponse,
} from "@shared/ClineAccount"
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"
import { ClineEnv } from "@/config"
import { buildBasicClineHeaders } from "@/services/EnvUtils"
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { AuthService } from "./auth-service"
export class ClineAccountService {
private static instance: ClineAccountService
private _authService: AuthService
constructor() {
this._authService = AuthService.getInstance()
}
/**
* Returns the singleton instance of ClineAccountService
*/
public static getInstance(): ClineAccountService {
if (!ClineAccountService.instance) {
ClineAccountService.instance = new ClineAccountService()
}
return ClineAccountService.instance
}
/**
* Returns the base URL for the Cline API
*/
get baseUrl(): string {
return ClineEnv.config().apiBaseUrl
}
/**
* Helper function to make authenticated requests to the Cline API.
* Uses the SDK-backed AuthService for token management.
*/
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
const url = new URL(endpoint, this.baseUrl).toString()
// IMPORTANT: Prefixed with 'workos:' so backend can route verification to WorkOS provider
const clineAccountAuthToken = await this._authService.getAuthToken()
if (!clineAccountAuthToken) {
throw new Error("No Cline account auth token found")
}
const requestConfig: AxiosRequestConfig = {
...config,
headers: {
Authorization: `Bearer ${clineAccountAuthToken}`,
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
...config.headers,
},
...getAxiosSettings(),
}
const response: AxiosResponse<{ data?: T; error: string; success: boolean }> = await axios.request({
url,
method: "GET",
...requestConfig,
})
const status = response.status
if (status < 200 || status >= 300) {
throw new Error(`Request to ${endpoint} failed with status ${status}`)
}
if (response.statusText !== "No Content" && (!response.data || !response.data.data)) {
throw new Error(`Invalid response from ${endpoint} API`)
}
if (typeof response.data === "object" && !response.data.success) {
throw new Error(`API error: ${response.data.error}`)
}
if (response.statusText === "No Content") {
return {} as T
}
return response.data.data as T
}
/**
* RPC variant that fetches the user's current credit balance
*/
async fetchBalanceRPC(): Promise<BalanceResponse | undefined> {
try {
const me = this.getCurrentUser()
if (!me || !me.uid) {
Logger.error("Failed to fetch user ID for balance")
return undefined
}
const data = await this.authenticatedRequest<BalanceResponse>(`/api/v1/users/${me.uid}/balance`)
return data
} catch (error) {
Logger.error("Failed to fetch balance (RPC):", error)
return undefined
}
}
/**
* RPC variant that fetches the user's usage transactions
*/
async fetchUsageTransactionsRPC(): Promise<UsageTransaction[] | undefined> {
try {
const me = this.getCurrentUser()
if (!me || !me.uid) {
Logger.error("Failed to fetch user ID for usage transactions")
return undefined
}
const data = await this.authenticatedRequest<{ items: UsageTransaction[] }>(`/api/v1/users/${me.uid}/usages`)
return data.items
} catch (error) {
Logger.error("Failed to fetch usage transactions (RPC):", error)
return undefined
}
}
/**
* RPC variant that fetches the user's payment transactions
*/
async fetchPaymentTransactionsRPC(): Promise<PaymentTransaction[] | undefined> {
try {
const me = this.getCurrentUser()
if (!me || !me.uid) {
Logger.error("Failed to fetch user ID for payment transactions")
return undefined
}
const data = await this.authenticatedRequest<{ paymentTransactions: PaymentTransaction[] }>(
`/api/v1/users/${me.uid}/payments`,
)
return data.paymentTransactions
} catch (error) {
Logger.error("Failed to fetch payment transactions (RPC):", error)
return undefined
}
}
/**
* Fetches the current user data
*/
async fetchMe(): Promise<UserResponse | undefined> {
try {
const data = await this.authenticatedRequest<UserResponse>(CLINE_API_ENDPOINT.USER_INFO)
return data
} catch (error) {
Logger.error("Failed to fetch user data (RPC):", error)
return undefined
}
}
/**
* Fetches the current user's organizations
*/
async fetchUserOrganizationsRPC(): Promise<UserResponse["organizations"] | undefined> {
try {
const me = await this.fetchMe()
if (!me || !me.organizations) {
Logger.error("Failed to fetch user organizations")
return undefined
}
return me.organizations
} catch (error) {
Logger.error("Failed to fetch user organizations (RPC):", error)
return undefined
}
}
/**
* Fetches the current user's organization credits
*/
async fetchOrganizationCreditsRPC(organizationId: string): Promise<OrganizationBalanceResponse | undefined> {
try {
const data = await this.authenticatedRequest<OrganizationBalanceResponse>(
`/api/v1/organizations/${organizationId}/balance`,
)
return data
} catch (error) {
Logger.error("Failed to fetch organization balance (RPC):", error)
return undefined
}
}
/**
* Fetches the current user's organization transactions
*/
async fetchOrganizationUsageTransactionsRPC(organizationId: string): Promise<OrganizationUsageTransaction[] | undefined> {
try {
const organizations = this._authService.getUserOrganizations()
if (!organizations) {
Logger.error("Failed to get user organizations")
return undefined
}
const memberId = organizations.find((org) => org.organizationId === organizationId)?.memberId
if (!memberId) {
Logger.error("Failed to find member ID for organization transactions")
return undefined
}
const data = await this.authenticatedRequest<{ items: OrganizationUsageTransaction[] }>(
`/api/v1/organizations/${organizationId}/members/${memberId}/usages`,
)
return data.items
} catch (error) {
Logger.error("Failed to fetch organization transactions (RPC):", error)
return undefined
}
}
/**
* Submits a spend limit increase request to the user's org admin.
*/
async submitLimitIncreaseRequestRPC(): Promise<void> {
try {
await this.authenticatedRequest<void>("/api/v1/users/me/budget/request", {
method: "POST",
})
} catch (error) {
Logger.error("Failed to submit limit increase request (RPC):", error)
throw error
}
}
/**
* Switches the active account to the specified organization or personal account.
*/
async switchAccount(organizationId?: string): Promise<void> {
try {
await this.authenticatedRequest<string>(CLINE_API_ENDPOINT.ACTIVE_ACCOUNT, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: {
organizationId: organizationId || null,
},
})
const activeOrgId = this._authService.getActiveOrganizationId()
if (activeOrgId !== organizationId) {
// Force a refresh of the auth info after switching
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
}
} catch (error) {
Logger.error("Error switching account:", error)
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
throw error
}
}
private getCurrentUser() {
return this._authService.getInfo().user
}
}
+453
View File
@@ -0,0 +1,453 @@
// Tests for the SDK-backed AuthService (Step 6: Auth & Account Flows)
//
// These tests verify the auth service's core logic:
// - Token persistence (read/write/clear from secrets)
// - Auth state management (authenticated/unauthenticated)
// - Auth info conversion (SDK OAuthCredentials → ClineAuthInfo)
// - Logout flow
// - Streaming subscription management
// - workos: prefix handling
import type { OAuthCredentials } from "@clinebot/core"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { AuthService, type ClineAuthInfo, LogoutReason } from "./auth-service"
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// Mock StateManager
const mockSecrets = new Map<string, string>()
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({
getSecretKey: (key: string) => mockSecrets.get(key) ?? undefined,
setSecret: (key: string, value: string | undefined) => {
if (value === undefined) {
mockSecrets.delete(key)
} else {
mockSecrets.set(key, value)
}
},
getGlobalSettingsKey: () => "act",
setGlobalState: vi.fn(),
}),
},
}))
// Mock ClineEnv
vi.mock("@/config", () => ({
ClineEnv: {
config: () => ({
apiBaseUrl: "https://api.cline.bot",
appBaseUrl: "https://app.cline.bot",
}),
},
}))
// Mock grpc-handler
vi.mock("@/core/controller/grpc-handler", () => ({
getRequestRegistry: () => ({
registerRequest: vi.fn(),
}),
}))
// Mock HostProvider
vi.mock("@/hosts/host-provider", () => ({
HostProvider: {
get: () => ({
getCallbackUrl: async (path: string) => `vscode://cline.cline${path}`,
}),
},
}))
// Mock openExternal
vi.mock("@/utils/env", () => ({
openExternal: vi.fn(),
}))
// Mock net
vi.mock("@/shared/net", () => ({
fetch: vi.fn(),
getAxiosSettings: () => ({}),
}))
// Mock buildBasicClineHeaders
vi.mock("@/services/EnvUtils", () => ({
buildBasicClineHeaders: async () => ({}),
}))
// Mock axios
vi.mock("axios", () => ({
default: {
get: vi.fn(),
},
}))
// Mock @clinebot/core OAuth functions
vi.mock("@clinebot/core", () => ({
createOAuthClientCallbacks: (opts: { onPrompt: () => void }) => ({
onAuth: vi.fn(),
onPrompt: opts.onPrompt,
}),
loginClineOAuth: vi.fn(),
loginOcaOAuth: vi.fn(),
loginOpenAICodex: vi.fn(),
refreshClineToken: vi.fn(),
getValidClineCredentials: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Test helpers — typed access to private members for testing
// ---------------------------------------------------------------------------
/** Type that exposes private members for test access */
interface AuthServiceTestAccess {
_clineAuthInfo: ClineAuthInfo | null
_authenticated: boolean
_activeAuthStatusUpdateHandlers: Map<string, unknown>
instance: AuthService | null
readAuthInfoFromSecrets(): ClineAuthInfo | null
writeAuthInfoToSecrets(info: ClineAuthInfo): void
clearAuthInfoFromSecrets(): void
}
function testAccess(service: AuthService): AuthServiceTestAccess {
// biome-ignore lint/suspicious/noExplicitAny: test-only access to private members
return service as any
}
function resetSingleton(): void {
// biome-ignore lint/suspicious/noExplicitAny: test-only reset of singleton
;(AuthService as any).instance = null
}
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
function createTestAuthInfo(overrides?: Partial<ClineAuthInfo>): ClineAuthInfo {
return {
idToken: "test-access-token",
refreshToken: "test-refresh-token",
expiresAt: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now (seconds)
userInfo: {
id: "user-123",
email: "test@example.com",
displayName: "Test User",
organizations: [
{
active: true,
memberId: "member-1",
name: "Personal",
organizationId: "org-personal",
roles: ["owner"],
},
],
},
provider: "cline",
startedAt: Date.now(),
...overrides,
}
}
function createTestOAuthCredentials(): OAuthCredentials {
return {
access: "oauth-access-token",
refresh: "oauth-refresh-token",
expires: Date.now() + 3600 * 1000, // 1 hour from now (ms)
accountId: "acct-456",
email: "oauth@example.com",
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("AuthService", () => {
let authService: AuthService
beforeEach(() => {
// Reset the singleton between tests
resetSingleton()
authService = AuthService.getInstance()
mockSecrets.clear()
vi.clearAllMocks()
})
describe("singleton pattern", () => {
it("returns the same instance on multiple calls", () => {
const instance1 = AuthService.getInstance()
const instance2 = AuthService.getInstance()
expect(instance1).toBe(instance2)
})
})
describe("getInfo() — auth state for webview", () => {
it("returns unauthenticated state when not logged in", () => {
const info = authService.getInfo()
expect(info.user).toBeNull()
})
it("returns authenticated state with user info when logged in", () => {
const authInfo = createTestAuthInfo()
testAccess(authService)._clineAuthInfo = authInfo
testAccess(authService)._authenticated = true
const info = authService.getInfo()
expect(info.user).not.toBeNull()
expect(info.user?.uid).toBe("user-123")
expect(info.user?.email).toBe("test@example.com")
expect(info.user?.displayName).toBe("Test User")
})
it("returns unauthenticated state when _authenticated is false even with auth info", () => {
const authInfo = createTestAuthInfo()
testAccess(authService)._clineAuthInfo = authInfo
testAccess(authService)._authenticated = false
const info = authService.getInfo()
expect(info.user).toBeNull()
})
})
describe("getActiveOrganizationId()", () => {
it("returns null when not authenticated", () => {
expect(authService.getActiveOrganizationId()).toBeNull()
})
it("returns the active organization ID when authenticated", () => {
const authInfo = createTestAuthInfo()
testAccess(authService)._clineAuthInfo = authInfo
expect(authService.getActiveOrganizationId()).toBe("org-personal")
})
it("returns null when no active organization exists", () => {
const authInfo = createTestAuthInfo({
userInfo: {
...createTestAuthInfo().userInfo,
organizations: [
{
active: false,
memberId: "member-1",
name: "Personal",
organizationId: "org-personal",
roles: ["owner"],
},
],
},
})
testAccess(authService)._clineAuthInfo = authInfo
expect(authService.getActiveOrganizationId()).toBeNull()
})
})
describe("getUserOrganizations()", () => {
it("returns undefined when not authenticated", () => {
expect(authService.getUserOrganizations()).toBeUndefined()
})
it("returns organizations when authenticated", () => {
const authInfo = createTestAuthInfo()
testAccess(authService)._clineAuthInfo = authInfo
const orgs = authService.getUserOrganizations()
expect(orgs).toHaveLength(1)
expect(orgs?.[0].organizationId).toBe("org-personal")
})
})
describe("getProviderName()", () => {
it("returns null when not authenticated", () => {
expect(authService.getProviderName()).toBeNull()
})
it("returns the provider name when authenticated", () => {
const authInfo = createTestAuthInfo()
testAccess(authService)._clineAuthInfo = authInfo
expect(authService.getProviderName()).toBe("cline")
})
})
describe("getAuthToken()", () => {
it("returns null when not authenticated", async () => {
expect(await authService.getAuthToken()).toBeNull()
})
it("returns workos:-prefixed token when authenticated", async () => {
const authInfo = createTestAuthInfo()
testAccess(authService)._clineAuthInfo = authInfo
testAccess(authService)._authenticated = true
const token = await authService.getAuthToken()
expect(token).toBe("workos:test-access-token")
})
it("returns null when token is expired and refresh fails", async () => {
const authInfo = createTestAuthInfo({
expiresAt: Math.floor(Date.now() / 1000) - 100, // expired
})
testAccess(authService)._clineAuthInfo = authInfo
testAccess(authService)._authenticated = true
// No refresh token → can't refresh
authInfo.refreshToken = undefined
const token = await authService.getAuthToken()
expect(token).toBeNull()
})
})
describe("handleDeauth() — logout", () => {
it("clears auth state and pushes unauthenticated state", async () => {
const authInfo = createTestAuthInfo()
testAccess(authService)._clineAuthInfo = authInfo
testAccess(authService)._authenticated = true
// Store something in secrets
mockSecrets.set("cline:clineAccountId", JSON.stringify(authInfo))
await authService.handleDeauth(LogoutReason.USER_INITIATED)
// Auth state should be cleared
expect(testAccess(authService)._clineAuthInfo).toBeNull()
expect(testAccess(authService)._authenticated).toBe(false)
// Secrets should be cleared
expect(mockSecrets.has("cline:clineAccountId")).toBe(false)
expect(mockSecrets.has("clineAccountId")).toBe(false)
})
})
describe("token persistence", () => {
it("reads auth info from secrets", () => {
const authInfo = createTestAuthInfo()
mockSecrets.set("cline:clineAccountId", JSON.stringify(authInfo))
const result = testAccess(authService).readAuthInfoFromSecrets()
expect(result).not.toBeNull()
expect(result?.idToken).toBe("test-access-token")
expect(result?.userInfo.id).toBe("user-123")
})
it("returns null when no secrets exist", () => {
const result = testAccess(authService).readAuthInfoFromSecrets()
expect(result).toBeNull()
})
it("returns null for corrupt JSON", () => {
mockSecrets.set("cline:clineAccountId", "not-valid-json")
const result = testAccess(authService).readAuthInfoFromSecrets()
expect(result).toBeNull()
})
it("writes auth info to secrets", () => {
const authInfo = createTestAuthInfo()
testAccess(authService).writeAuthInfoToSecrets(authInfo)
const stored = mockSecrets.get("cline:clineAccountId")
expect(stored).toBeDefined()
const parsed = JSON.parse(stored ?? "{}")
expect(parsed.idToken).toBe("test-access-token")
})
it("clears auth info from secrets", () => {
mockSecrets.set("cline:clineAccountId", "some-value")
mockSecrets.set("clineAccountId", "legacy-value")
testAccess(authService).clearAuthInfoFromSecrets()
expect(mockSecrets.has("cline:clineAccountId")).toBe(false)
expect(mockSecrets.has("clineAccountId")).toBe(false)
})
})
describe("restoreRefreshTokenAndRetrieveAuthInfo()", () => {
it("restores auth state from secrets on startup", async () => {
const authInfo = createTestAuthInfo()
mockSecrets.set("cline:clineAccountId", JSON.stringify(authInfo))
await authService.restoreRefreshTokenAndRetrieveAuthInfo()
expect(testAccess(authService)._authenticated).toBe(true)
expect(testAccess(authService)._clineAuthInfo).not.toBeNull()
expect(testAccess(authService)._clineAuthInfo?.idToken).toBe("test-access-token")
})
it("sets unauthenticated state when no secrets exist", async () => {
await authService.restoreRefreshTokenAndRetrieveAuthInfo()
expect(testAccess(authService)._authenticated).toBe(false)
expect(testAccess(authService)._clineAuthInfo).toBeNull()
})
})
describe("LogoutReason enum", () => {
it("has expected values", () => {
expect(LogoutReason.USER_INITIATED).toBe("user_initiated")
expect(LogoutReason.CROSS_WINDOW_SYNC).toBe("cross_window_sync")
expect(LogoutReason.ERROR_RECOVERY).toBe("error_recovery")
expect(LogoutReason.UNKNOWN).toBe("unknown")
})
})
describe("workos: prefix handling", () => {
it("getAuthToken always returns workos:-prefixed token", async () => {
const authInfo = createTestAuthInfo()
testAccess(authService)._clineAuthInfo = authInfo
testAccess(authService)._authenticated = true
const token = await authService.getAuthToken()
expect(token).toMatch(/^workos:/)
expect(token).toBe("workos:test-access-token")
})
})
describe("streaming subscriptions", () => {
it("subscribeToAuthStatusUpdate pushes initial state immediately", async () => {
const mockResponseStream = vi.fn()
const mockController = { postStateToWebview: vi.fn() }
await authService.subscribeToAuthStatusUpdate(
// biome-ignore lint/suspicious/noExplicitAny: mock controller for testing
mockController as any,
{},
// biome-ignore lint/suspicious/noExplicitAny: mock response stream for testing
mockResponseStream as any,
"test-request-id",
)
// Should have pushed initial auth state
expect(mockResponseStream).toHaveBeenCalled()
const [authState] = mockResponseStream.mock.calls[0]
expect(authState).toBeDefined()
expect(authState.user).toBeNull() // Not authenticated in this test
})
it("removes subscription on cleanup", async () => {
const mockResponseStream = vi.fn().mockResolvedValue(undefined)
const mockController = { postStateToWebview: vi.fn() }
await authService.subscribeToAuthStatusUpdate(
// biome-ignore lint/suspicious/noExplicitAny: mock controller for testing
mockController as any,
{},
// biome-ignore lint/suspicious/noExplicitAny: mock response stream for testing
mockResponseStream as any,
)
// Should have one handler
expect(testAccess(authService)._activeAuthStatusUpdateHandlers.size).toBe(1)
// Simulate cleanup
testAccess(authService)._activeAuthStatusUpdateHandlers.clear()
expect(testAccess(authService)._activeAuthStatusUpdateHandlers.size).toBe(0)
})
})
})
// Ensure createTestOAuthCredentials is used (suppress unused warning)
void createTestOAuthCredentials
+892
View File
@@ -0,0 +1,892 @@
// Replaces classic src/services/auth/AuthService.ts (see origin/main)
//
// SDK-backed authentication service. Uses @clinebot/core OAuth functions
// for login flows and ProviderSettingsManager (providers.json) as the
// single source of truth for credentials.
//
// User profile info (email, displayName, organizations) is NOT stored on
// disk — it's fetched from the Cline API on startup and cached in memory.
// This matches the CLI's pattern (see apps/cli/src/runtime/interactive-welcome.ts).
import type { OAuthCredentials } from "@clinebot/core"
import { createOAuthClientCallbacks, loginClineOAuth, loginOcaOAuth, loginOpenAICodex, refreshClineToken } from "@clinebot/core"
import type { ApiProvider } from "@shared/api"
import { AuthState, UserInfo } from "@shared/proto/cline/account"
import type { EmptyRequest, String } from "@shared/proto/cline/common"
import axios from "axios"
import { ClineEnv } from "@/config"
import type { Controller } from "@/core/controller"
import { getRequestRegistry, type StreamingResponseHandler } from "@/core/controller/grpc-handler"
import { StateManager } from "@/core/storage/StateManager"
import { HostProvider } from "@/hosts/host-provider"
import { BannerService } from "@/services/banner/BannerService"
import { buildBasicClineHeaders } from "@/services/EnvUtils"
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
import { fetch, getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { openExternal } from "@/utils/env"
import { getProviderSettingsManager } from "./provider-migration"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Shape of the auth info cached in memory (NOT persisted to disk). */
export interface ClineAuthInfo {
idToken: string
refreshToken?: string
expiresAt?: number // seconds since epoch
userInfo: ClineAccountUserInfo
provider: string
startedAt?: number
}
export interface ClineAccountUserInfo {
createdAt?: string
displayName: string
email: string
id: string
organizations: ClineAccountOrganization[]
appBaseUrl?: string
subject?: string
}
export interface ClineAccountOrganization {
active: boolean
memberId: string
name: string
organizationId: string
roles: string[]
}
/** Logout reason for telemetry */
export enum LogoutReason {
USER_INITIATED = "user_initiated",
CROSS_WINDOW_SYNC = "cross_window_sync",
ERROR_RECOVERY = "error_recovery",
UNKNOWN = "unknown",
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const WORKOS_TOKEN_PREFIX = "workos:"
// ---------------------------------------------------------------------------
// providers.json helpers
// ---------------------------------------------------------------------------
/**
* Read Cline OAuth credentials from providers.json.
* Returns { accessToken, refreshToken, expiresAt, accountId } or null.
*/
function readClineCredentials(): {
accessToken: string
refreshToken?: string
expiresAt?: number // milliseconds since epoch (providers.json convention)
accountId?: string
} | null {
try {
const manager = getProviderSettingsManager()
const settings = manager.getProviderSettings("cline")
if (!settings?.auth?.accessToken) return null
// Strip workos: prefix if present (providers.json stores it with prefix)
let accessToken = settings.auth.accessToken
if (accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)) {
accessToken = accessToken.slice(WORKOS_TOKEN_PREFIX.length)
}
return {
accessToken,
refreshToken: settings.auth.refreshToken,
expiresAt: (settings.auth as { expiresAt?: number }).expiresAt,
accountId: settings.auth.accountId,
}
} catch (error) {
Logger.error("[SdkAuthService] Failed to read credentials from providers.json:", error)
return null
}
}
/**
* Write Cline OAuth credentials to providers.json.
*/
function writeClineCredentials(credentials: {
accessToken: string
refreshToken?: string
expiresAt?: number // milliseconds since epoch
accountId?: string
}): void {
try {
const manager = getProviderSettingsManager()
const existing = manager.getProviderSettings("cline")
const auth = {
...(existing?.auth ?? {}),
accessToken: `${WORKOS_TOKEN_PREFIX}${credentials.accessToken}`,
refreshToken: credentials.refreshToken,
accountId: credentials.accountId,
} as Record<string, unknown>
if (credentials.expiresAt !== undefined) {
auth.expiresAt = credentials.expiresAt
}
manager.saveProviderSettings(
{
...(existing ?? { provider: "cline" }),
provider: "cline",
auth: auth as { accessToken?: string; refreshToken?: string; accountId?: string },
},
{ tokenSource: "oauth", setLastUsed: true },
)
} catch (error) {
Logger.error("[SdkAuthService] Failed to write credentials to providers.json:", error)
}
}
/**
* Clear Cline OAuth credentials from providers.json.
*/
function clearClineCredentials(): void {
try {
const manager = getProviderSettingsManager()
const existing = manager.getProviderSettings("cline")
if (existing) {
manager.saveProviderSettings(
{
...existing,
provider: "cline",
auth: undefined,
},
{ tokenSource: "manual" },
)
}
} catch (error) {
Logger.error("[SdkAuthService] Failed to clear credentials from providers.json:", error)
}
}
// ---------------------------------------------------------------------------
// AuthService
// ---------------------------------------------------------------------------
export class AuthService {
private static instance: AuthService | null = null
private _authenticated = false
private _clineAuthInfo: ClineAuthInfo | null = null
private _activeAuthStatusUpdateHandlers = new Set<StreamingResponseHandler<AuthState>>()
private _handlerToController = new Map<StreamingResponseHandler<AuthState>, Controller>()
private _refreshPromise: Promise<string | undefined> | null = null
private constructor() {}
/**
* Gets the singleton instance of AuthService.
* On first call with a controller, initializes BannerService.
*/
public static getInstance(controller?: Controller): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService()
}
// Initialize BannerService on first call with a controller
// (mirrors classic AuthService behavior)
if (controller) {
try {
BannerService.initialize(controller)
} catch {
// BannerService may already be initialized — that's fine
}
}
return AuthService.instance
}
set controller(_controller: Controller) {
// Kept for interface compatibility — not needed in SDK-backed version
}
// ---- SDK OAuth → ClineAuthInfo conversion ----
/**
* Convert SDK OAuthCredentials to our ClineAuthInfo format.
* Also fetches full user info from the Cline API.
*/
private async credentialsToAuthInfo(credentials: OAuthCredentials, provider: string): Promise<ClineAuthInfo> {
// Fetch full user info from the API using the access token
const userInfo = await this.fetchUserInfoFromApi(credentials.access)
return {
idToken: credentials.access,
refreshToken: credentials.refresh,
expiresAt: credentials.expires ? credentials.expires / 1000 : undefined, // SDK uses ms, we store seconds
userInfo: userInfo ?? {
id: credentials.accountId ?? "",
email: credentials.email ?? "",
displayName: "",
organizations: [],
},
provider,
startedAt: Date.now(),
}
}
/**
* Fetch user info from the Cline API using an access token.
*/
private async fetchUserInfoFromApi(accessToken: string): Promise<ClineAccountUserInfo | null> {
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
// Ensure the token has the workos: prefix for the API
const bearerToken = accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
? accessToken
: `${WORKOS_TOKEN_PREFIX}${accessToken}`
const response = await axios.get(`${apiBaseUrl}/api/v1/users/me`, {
headers: {
Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
},
...getAxiosSettings(),
})
return response.data?.data ?? null
} catch (error) {
Logger.error("[SdkAuthService] Failed to fetch user info from API:", error)
return null
}
}
// ---- Public API (used by gRPC handlers) ----
/**
* Returns the current authentication token with the `workos:` prefix.
* Refreshes if necessary using the SDK's token management.
*/
async getAuthToken(): Promise<string | null> {
if (!this._clineAuthInfo?.idToken) {
return null
}
// Check if we need to refresh
const expiresAt = this._clineAuthInfo.expiresAt
if (expiresAt) {
const currentTime = Date.now() / 1000
const bufferSeconds = 5 * 60 // 5 minute buffer
if (currentTime + bufferSeconds >= expiresAt) {
// Token is expired or about to expire — try to refresh
const refreshed = await this.refreshAccessToken()
if (!refreshed) {
return null
}
}
}
// Verify the token is still valid (not past expiry)
if (expiresAt && Date.now() / 1000 >= expiresAt) {
return null
}
return `${WORKOS_TOKEN_PREFIX}${this._clineAuthInfo.idToken}`
}
/**
* Refresh the access token using the SDK's refreshClineToken().
* Persists refreshed credentials to providers.json.
*/
private async refreshAccessToken(): Promise<boolean> {
if (this._refreshPromise) {
await this._refreshPromise
return this._clineAuthInfo?.idToken !== undefined
}
if (!this._clineAuthInfo?.refreshToken) {
return false
}
this._refreshPromise = (async () => {
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const currentInfo = this._clineAuthInfo
if (!currentInfo?.refreshToken) {
return undefined
}
const newCredentials = await refreshClineToken(
{
access: currentInfo.idToken,
refresh: currentInfo.refreshToken,
expires: currentInfo.expiresAt ? currentInfo.expiresAt * 1000 : 0,
accountId: currentInfo.userInfo.id,
email: currentInfo.userInfo.email,
},
{ apiBaseUrl },
)
// Update auth info with new credentials
const userInfo = await this.fetchUserInfoFromApi(newCredentials.access)
this._clineAuthInfo = {
idToken: newCredentials.access,
refreshToken: newCredentials.refresh,
expiresAt: newCredentials.expires ? newCredentials.expires / 1000 : undefined,
userInfo: userInfo ?? currentInfo.userInfo,
provider: currentInfo.provider,
startedAt: currentInfo.startedAt ?? Date.now(),
}
this._authenticated = true
// Persist refreshed credentials to providers.json
writeClineCredentials({
accessToken: newCredentials.access,
refreshToken: newCredentials.refresh,
expiresAt: newCredentials.expires,
accountId: this._clineAuthInfo.userInfo.id,
})
// Push auth state update
setImmediate(() => {
this.sendAuthStatusUpdate().catch((err) => {
Logger.error("[SdkAuthService] Error sending auth status update after refresh:", err)
})
})
return this._clineAuthInfo.idToken
} catch (error) {
Logger.error("[SdkAuthService] Token refresh failed:", error)
// If it's a permanent failure (invalid token), clear auth state
if (error instanceof Error && (error.message.includes("401") || error.message.includes("400"))) {
this._clineAuthInfo = null
this._authenticated = false
clearClineCredentials()
setImmediate(() => {
this.sendAuthStatusUpdate().catch(() => {})
})
}
return undefined
} finally {
this._refreshPromise = null
}
})()
const result = await this._refreshPromise
return result !== undefined
}
/**
* Gets the active organization ID from the authenticated user's info.
*/
getActiveOrganizationId(): string | null {
if (!this._clineAuthInfo?.userInfo?.organizations) return null
const activeOrg = this._clineAuthInfo.userInfo.organizations.find((org) => org.active)
return activeOrg?.organizationId ?? null
}
/**
* Gets all organizations from the authenticated user's info.
*/
getUserOrganizations(): ClineAccountOrganization[] | undefined {
return this._clineAuthInfo?.userInfo?.organizations
}
/**
* Gets the provider name for the current authentication.
*/
getProviderName(): string | null {
return this._clineAuthInfo?.provider ?? null
}
/**
* Returns the current auth state for the webview.
*/
getInfo(): AuthState {
if (this._clineAuthInfo && this._authenticated) {
const userInfo = this._clineAuthInfo.userInfo
userInfo.appBaseUrl = ClineEnv.config().appBaseUrl
const user = UserInfo.create({
uid: userInfo?.id,
displayName: userInfo?.displayName,
email: userInfo?.email,
photoUrl: undefined,
appBaseUrl: userInfo?.appBaseUrl,
})
return AuthState.create({ user })
}
return AuthState.create({})
}
// ---- Login flows ----
/**
* Initiate Cline OAuth login.
* Uses SDK's loginClineOAuth() which spawns a local callback server.
* Persists credentials to providers.json.
*/
async createAuthRequest(strict = false): Promise<String> {
// In strict mode, don't open a new auth window if already authenticated
if (strict && this._authenticated) {
await this.sendAuthStatusUpdate()
const { String: ProtoString } = await import("@shared/proto/cline/common")
return ProtoString.create({ value: "Already authenticated" })
}
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const callbacks = createOAuthClientCallbacks({
onPrompt: async (prompt) => prompt.defaultValue ?? "",
openUrl: async (url: string) => {
await openExternal(url)
},
onOpenUrlError: ({ url, error }) => {
Logger.error(`[SdkAuthService] Failed to open browser for ${url}:`, error)
},
})
const credentials = await loginClineOAuth({
apiBaseUrl,
callbacks,
})
// Convert and persist to providers.json
const authInfo = await this.credentialsToAuthInfo(credentials, "cline")
this._clineAuthInfo = authInfo
this._authenticated = true
writeClineCredentials({
accessToken: credentials.access,
refreshToken: credentials.refresh,
expiresAt: credentials.expires,
accountId: authInfo.userInfo.id || credentials.accountId,
})
// Push auth state update
await this.sendAuthStatusUpdate()
// Notify BannerService of auth change (mirrors classic AuthService)
BannerService.onAuthUpdate(authInfo.userInfo?.id || null).catch((error) => {
Logger.error("[SdkAuthService] Banner update failed after login", error)
})
const { String: ProtoString } = await import("@shared/proto/cline/common")
return ProtoString.create({ value: "Authenticated" })
} catch (error) {
Logger.error("[SdkAuthService] Cline OAuth login failed:", error)
throw error
}
}
/**
* Initiate OCA OAuth login.
*/
async ocaLogin(): Promise<String> {
try {
const callbacks = createOAuthClientCallbacks({
onPrompt: async (prompt) => prompt.defaultValue ?? "",
openUrl: async (url: string) => {
await openExternal(url)
},
onOpenUrlError: ({ url, error }) => {
Logger.error(`[SdkAuthService] Failed to open browser for OCA: ${url}:`, error)
},
})
const credentials = await loginOcaOAuth({ callbacks })
const authInfo = await this.credentialsToAuthInfo(credentials, "oca")
this._clineAuthInfo = authInfo
this._authenticated = true
writeClineCredentials({
accessToken: credentials.access,
refreshToken: credentials.refresh,
expiresAt: credentials.expires,
accountId: authInfo.userInfo.id || credentials.accountId,
})
await this.sendAuthStatusUpdate()
const { String: ProtoString } = await import("@shared/proto/cline/common")
return ProtoString.create({ value: "Authenticated" })
} catch (error) {
Logger.error("[SdkAuthService] OCA OAuth login failed:", error)
throw error
}
}
/**
* Initiate OpenAI Codex OAuth login.
*/
async openAiCodexLogin(): Promise<void> {
try {
const callbacks = createOAuthClientCallbacks({
onPrompt: async (prompt) => prompt.defaultValue ?? "",
openUrl: async (url: string) => {
await openExternal(url)
},
onOpenUrlError: ({ url, error }) => {
Logger.error(`[SdkAuthService] Failed to open browser for Codex: ${url}:`, error)
},
})
const credentials = await loginOpenAICodex(callbacks)
// Store Codex credentials in providers.json
await this.saveCodexCredentials(credentials)
// Notify webview of state change
await this.sendAuthStatusUpdate()
} catch (error) {
Logger.error("[SdkAuthService] OpenAI Codex OAuth login failed:", error)
throw error
}
}
/**
* Save Codex OAuth credentials to provider settings.
*/
private async saveCodexCredentials(credentials: OAuthCredentials): Promise<void> {
try {
const manager = getProviderSettingsManager()
const existing = manager.getProviderSettings("openai-codex")
manager.saveProviderSettings(
{
...(existing ?? { provider: "openai-codex" }),
provider: "openai-codex",
auth: {
accessToken: credentials.access,
refreshToken: credentials.refresh,
accountId: credentials.accountId,
},
},
{ tokenSource: "oauth" },
)
} catch (error) {
Logger.error("[SdkAuthService] Failed to save Codex credentials:", error)
}
}
/**
* Clear Codex credentials from provider settings.
*/
async clearCodexCredentials(): Promise<void> {
try {
const manager = getProviderSettingsManager()
const existing = manager.getProviderSettings("openai-codex")
if (existing) {
manager.saveProviderSettings(
{
...existing,
provider: "openai-codex",
auth: undefined,
},
{ tokenSource: "manual" },
)
}
} catch (error) {
Logger.error("[SdkAuthService] Failed to clear Codex credentials:", error)
}
}
// ---- Logout ----
/**
* Handle deauthentication — clear tokens from providers.json and push unauthenticated state.
*/
async handleDeauth(_reason: LogoutReason = LogoutReason.UNKNOWN): Promise<void> {
try {
this._clineAuthInfo = null
this._authenticated = false
clearClineCredentials()
await this.sendAuthStatusUpdate()
// Notify BannerService of auth change (mirrors classic AuthService)
BannerService.onAuthUpdate(null).catch((error) => {
Logger.error("[SdkAuthService] Banner update failed after logout", error)
})
} catch (error) {
Logger.error("[SdkAuthService] Error signing out:", error)
throw error
}
}
/**
* Handle auth callback from URI handler.
* This is called when the browser redirects back to the extension after OAuth.
*/
async handleAuthCallback(authorizationCode: string, provider: string): Promise<void> {
try {
// Exchange the authorization code for tokens using the Cline API
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const callbackUrl = await HostProvider.get().getCallbackUrl("/auth")
const tokenUrl = new URL(CLINE_API_ENDPOINT.TOKEN_EXCHANGE, apiBaseUrl)
const response = await fetch(tokenUrl.toString(), {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...(await buildBasicClineHeaders()),
},
body: JSON.stringify({
grant_type: "authorization_code",
code: authorizationCode,
client_type: "extension",
redirect_uri: callbackUrl,
provider: provider,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error_description || "Failed to exchange authorization code for tokens")
}
const responseJSON = await response.json()
const tokenData = responseJSON.data
if (!tokenData.accessToken || !tokenData.refreshToken || !tokenData.userInfo) {
throw new Error("Invalid token response from server")
}
// Fetch full user info
const userInfo = await this.fetchUserInfoFromApi(tokenData.accessToken)
const authInfo: ClineAuthInfo = {
idToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken,
userInfo: userInfo ?? {
id: tokenData.userInfo.clineUserId || "",
email: tokenData.userInfo.email || "",
displayName: tokenData.userInfo.name || "",
createdAt: new Date().toISOString(),
organizations: [],
},
expiresAt: new Date(tokenData.expiresAt).getTime() / 1000,
provider: "cline",
startedAt: Date.now(),
}
this._clineAuthInfo = authInfo
this._authenticated = true
// Persist to providers.json
writeClineCredentials({
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken,
expiresAt: new Date(tokenData.expiresAt).getTime(),
accountId: authInfo.userInfo.id,
})
await this.sendAuthStatusUpdate()
} catch (error) {
Logger.error("[SdkAuthService] Error handling auth callback:", error)
throw error
}
}
/**
* Handle OCA auth callback.
*/
async handleOcaAuthCallback(_code: string, _state: string): Promise<void> {
// OCA uses SDK's local callback server, so this shouldn't normally be called.
// Keeping it as a stub for interface compatibility.
Logger.warn("[SdkAuthService] handleOcaAuthCallback called — OCA uses SDK callback server")
}
/**
* Handle MCP OAuth callback.
*/
async handleMcpOAuthCallback(_serverHash: string, _code: string, _state: string | null): Promise<void> {
// Will be implemented in Step 7 (MCP Integration)
Logger.warn("[SdkAuthService] handleMcpOAuthCallback not yet implemented (Step 7)")
}
// ---- Restore auth on startup ----
/**
* Restore authentication from providers.json on startup.
*
* Reads tokens from providers.json, refreshes if needed, then fetches
* user profile from the API. This matches the CLI's pattern — credentials
* live in providers.json, user info is fetched fresh each session.
*/
async restoreRefreshTokenAndRetrieveAuthInfo(): Promise<void> {
try {
const creds = readClineCredentials()
if (!creds) {
this._authenticated = false
this._clineAuthInfo = null
return
}
// Build a minimal ClineAuthInfo from providers.json tokens
this._clineAuthInfo = {
idToken: creds.accessToken,
refreshToken: creds.refreshToken,
expiresAt: creds.expiresAt ? creds.expiresAt / 1000 : undefined, // providers.json uses ms, we use seconds
userInfo: {
id: creds.accountId ?? "",
email: "",
displayName: "",
organizations: [],
},
provider: "cline",
}
this._authenticated = true
// Try to refresh the token if it's expired
const expiresAt = this._clineAuthInfo.expiresAt
if (expiresAt) {
const currentTime = Date.now() / 1000
const bufferSeconds = 5 * 60
if (currentTime + bufferSeconds >= expiresAt && creds.refreshToken) {
await this.refreshAccessToken()
}
}
// Fetch full user info from the API (the key step — fills in
// email, displayName, organizations that providers.json doesn't store)
if (this._authenticated && this._clineAuthInfo) {
const userInfo = await this.fetchUserInfoFromApi(this._clineAuthInfo.idToken)
if (userInfo) {
this._clineAuthInfo.userInfo = userInfo
} else {
Logger.warn("[SdkAuthService] Could not fetch user info on restore — UI will show limited profile")
}
}
await this.sendAuthStatusUpdate()
// Notify BannerService of auth change (mirrors classic AuthService)
BannerService.onAuthUpdate(this._clineAuthInfo?.userInfo?.id || null).catch((error) => {
Logger.error("[SdkAuthService] Banner update failed after restore", error)
})
} catch (error) {
Logger.error("[SdkAuthService] Error restoring auth token:", error)
this._authenticated = false
this._clineAuthInfo = null
}
}
// ---- Streaming subscriptions ----
/**
* Subscribe to authStatusUpdate events.
* Pushes initial auth state immediately on subscribe.
*/
async subscribeToAuthStatusUpdate(
controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<AuthState>,
requestId?: string,
): Promise<void> {
this._activeAuthStatusUpdateHandlers.add(responseStream)
this._handlerToController.set(responseStream, controller)
const cleanup = () => {
this._activeAuthStatusUpdateHandlers.delete(responseStream)
this._handlerToController.delete(responseStream)
}
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "authStatusUpdate_subscription" }, responseStream)
}
// Push initial auth state immediately (prevents race condition)
try {
await this.sendAuthStatusUpdate()
} catch (error) {
Logger.error("[SdkAuthService] Error sending initial auth status:", error)
this._activeAuthStatusUpdateHandlers.delete(responseStream)
this._handlerToController.delete(responseStream)
}
}
/**
* Send an authStatusUpdate event to all active subscribers.
*/
async sendAuthStatusUpdate(): Promise<void> {
const authInfo: AuthState = this.getInfo()
const uniqueControllers = new Set<Controller>()
const streamSends = Array.from(this._activeAuthStatusUpdateHandlers).map(async (responseStream) => {
const controller = this._handlerToController.get(responseStream)
if (controller) {
uniqueControllers.add(controller)
}
try {
await responseStream(authInfo, false)
} catch (error) {
Logger.error("[SdkAuthService] Error sending authStatusUpdate event:", error)
this._activeAuthStatusUpdateHandlers.delete(responseStream)
this._handlerToController.delete(responseStream)
}
})
await Promise.all(streamSends)
// Update state in webviews once per unique controller
await Promise.all(Array.from(uniqueControllers).map((c) => c.postStateToWebview()))
}
// ---- Provider-specific auth callbacks ----
/**
* Shared helper: set a provider's API key and switch both plan/act modes to it.
*/
private setProviderApiKey(provider: ApiProvider, apiKeyField: string, apiKey: string): void {
const stateManager = StateManager.get()
const currentApiConfiguration = stateManager.getApiConfiguration()
const updatedConfig = {
...currentApiConfiguration,
planModeApiProvider: provider,
actModeApiProvider: provider,
[apiKeyField]: apiKey,
}
stateManager.setApiConfiguration(updatedConfig)
}
/**
* Handle OpenRouter OAuth callback.
*/
async handleOpenRouterCallback(code: string): Promise<void> {
let apiKey: string
try {
const response = await fetch("https://openrouter.ai/api/v1/auth/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
})
if (!response.ok) {
throw new Error(`OpenRouter API responded with status ${response.status}`)
}
const data = (await response.json()) as { key?: string }
if (data?.key) {
apiKey = data.key
} else {
throw new Error("Invalid response from OpenRouter API")
}
} catch (error) {
Logger.error("[SdkAuthService] Error exchanging code for API key:", error)
throw error
}
this.setProviderApiKey("openrouter", "openRouterApiKey", apiKey)
}
/**
* Handle Requesty OAuth callback.
*/
async handleRequestyCallback(code: string): Promise<void> {
this.setProviderApiKey("requesty", "requestyApiKey", code)
}
/**
* Handle Hicap OAuth callback.
*/
async handleHicapCallback(code: string): Promise<void> {
this.setProviderApiKey("hicap", "hicapApiKey", code)
}
}
+266
View File
@@ -0,0 +1,266 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import type { CoreSessionConfig } from "@clinebot/core"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import {
buildResumeSessionInput,
buildStartSessionInput,
createHistoryItemFromSession,
getHistoryItemById,
updateHistoryItem,
} from "./cline-session-factory"
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
function writeJson(filePath: string, data: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, JSON.stringify(data, null, 2))
}
function makeBaseConfig(overrides: Partial<CoreSessionConfig> = {}): CoreSessionConfig {
return {
providerId: "anthropic",
modelId: "claude-sonnet-4-6",
apiKey: "test-key",
cwd: "/tmp/workspace",
workspaceRoot: "/tmp/workspace",
systemPrompt: "",
enableTools: true,
enableSpawnAgent: false,
enableAgentTeams: false,
...overrides,
}
}
// ---------------------------------------------------------------------------
// buildStartSessionInput
// ---------------------------------------------------------------------------
describe("buildStartSessionInput", () => {
it("builds input with prompt", () => {
const config = makeBaseConfig()
const input = {
prompt: "Hello, world!",
cwd: "/tmp/workspace",
}
const result = buildStartSessionInput(config, input)
expect(result.config).toBe(config)
expect(result.prompt).toBe("Hello, world!")
expect(result.interactive).toBe(true)
expect(result.userImages).toBeUndefined()
expect(result.userFiles).toBeUndefined()
})
it("includes images and files when provided", () => {
const config = makeBaseConfig()
const input = {
prompt: "Look at this",
images: ["image1.png", "image2.jpg"],
files: ["file1.ts"],
cwd: "/tmp/workspace",
}
const result = buildStartSessionInput(config, input)
expect(result.userImages).toEqual(["image1.png", "image2.jpg"])
expect(result.userFiles).toEqual(["file1.ts"])
})
it("always sets interactive to true", () => {
const config = makeBaseConfig()
const input = { cwd: "/tmp/workspace" }
const result = buildStartSessionInput(config, input)
expect(result.interactive).toBe(true)
})
it("handles undefined prompt", () => {
const config = makeBaseConfig()
const input = { cwd: "/tmp/workspace" }
const result = buildStartSessionInput(config, input)
expect(result.prompt).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// buildResumeSessionInput
// ---------------------------------------------------------------------------
describe("buildResumeSessionInput", () => {
it("builds resume input with session ID and prompt", () => {
const result = buildResumeSessionInput("session-123", "Continue the task")
expect(result.sessionId).toBe("session-123")
expect(result.prompt).toBe("Continue the task")
expect(result.userImages).toBeUndefined()
expect(result.userFiles).toBeUndefined()
})
it("includes images and files when provided", () => {
const result = buildResumeSessionInput("session-123", "Look at this", ["img.png"], ["file.ts"])
expect(result.userImages).toEqual(["img.png"])
expect(result.userFiles).toEqual(["file.ts"])
})
})
// ---------------------------------------------------------------------------
// createHistoryItemFromSession
// ---------------------------------------------------------------------------
describe("createHistoryItemFromSession", () => {
it("creates a HistoryItem from session data", () => {
const item = createHistoryItemFromSession(
"session-abc",
"Fix the bug in main.ts",
"claude-sonnet-4-6",
"/home/user/project",
)
expect(item.id).toBe("session-abc")
expect(item.task).toBe("Fix the bug in main.ts")
expect(item.modelId).toBe("claude-sonnet-4-6")
expect(item.cwdOnTaskInitialization).toBe("/home/user/project")
expect(item.tokensIn).toBe(0)
expect(item.tokensOut).toBe(0)
expect(item.totalCost).toBe(0)
expect(item.ts).toBeGreaterThan(0)
})
it("handles missing optional fields", () => {
const item = createHistoryItemFromSession("session-xyz", "Simple task")
expect(item.modelId).toBeUndefined()
expect(item.cwdOnTaskInitialization).toBeUndefined()
})
it("creates unique timestamps for different calls", () => {
const item1 = createHistoryItemFromSession("s1", "Task 1")
const item2 = createHistoryItemFromSession("s2", "Task 2")
// Timestamps should be at least as large (may be same if called in same ms)
expect(item2.ts).toBeGreaterThanOrEqual(item1.ts)
})
})
// ---------------------------------------------------------------------------
// getHistoryItemById
// ---------------------------------------------------------------------------
describe("getHistoryItemById", () => {
it("returns undefined when task is not found", () => {
const result = getHistoryItemById("nonexistent", tempDir)
expect(result).toBeUndefined()
})
it("finds a task by ID", () => {
const history = [
{ id: "task-1", ts: Date.now(), task: "First task", tokensIn: 0, tokensOut: 0, totalCost: 0 },
{ id: "task-2", ts: Date.now(), task: "Second task", tokensIn: 0, tokensOut: 0, totalCost: 0 },
]
writeJson(path.join(tempDir, "state", "taskHistory.json"), history)
const result = getHistoryItemById("task-2", tempDir)
expect(result).toBeDefined()
expect(result?.id).toBe("task-2")
expect(result?.task).toBe("Second task")
})
it("returns undefined for empty history", () => {
writeJson(path.join(tempDir, "state", "taskHistory.json"), [])
const result = getHistoryItemById("task-1", tempDir)
expect(result).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// updateHistoryItem
// ---------------------------------------------------------------------------
describe("updateHistoryItem", () => {
it("adds a new item to history", () => {
writeJson(path.join(tempDir, "state", "taskHistory.json"), [])
const newItem: import("@shared/HistoryItem").HistoryItem = {
id: "task-new",
ts: Date.now(),
task: "New task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
}
const result = updateHistoryItem(newItem, tempDir)
expect(result).toHaveLength(1)
expect(result[0].id).toBe("task-new")
})
it("updates an existing item in history", () => {
const existingItem = {
id: "task-1",
ts: Date.now(),
task: "Original task",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
writeJson(path.join(tempDir, "state", "taskHistory.json"), [existingItem])
const updatedItem = {
...existingItem,
tokensIn: 500,
tokensOut: 250,
totalCost: 0.05,
}
const result = updateHistoryItem(updatedItem, tempDir)
expect(result).toHaveLength(1)
expect(result[0].tokensIn).toBe(500)
expect(result[0].totalCost).toBe(0.05)
})
it("prepends new items to the beginning of history", () => {
const existingItem = {
id: "task-old",
ts: Date.now() - 1000,
task: "Old task",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
writeJson(path.join(tempDir, "state", "taskHistory.json"), [existingItem])
const newItem = {
id: "task-new",
ts: Date.now(),
task: "New task",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
const result = updateHistoryItem(newItem, tempDir)
expect(result).toHaveLength(2)
expect(result[0].id).toBe("task-new")
expect(result[1].id).toBe("task-old")
})
})
+454
View File
@@ -0,0 +1,454 @@
// Replaces classic task creation from src/core/task/index.ts (see origin/main)
//
// Creates and manages SDK sessions using ClineCore. This factory handles:
// - Creating ClineCore instances with proper configuration
// - Building session config from legacy state (provider, model, API key)
// - Custom session persistence adapter reading ~/.cline/data/tasks/
// - Mapping HistoryItem ↔ SDK session fields
//
// The factory does NOT handle UI concerns — that's the SdkController's job.
import {
buildWorkspaceMetadata,
type CoreSessionConfig,
type SessionManager,
type StartSessionInput,
type StartSessionResult,
} from "@clinebot/core"
import { buildClineSystemPrompt } from "@clinebot/shared"
import type { ApiConfiguration } from "@shared/api"
import type { HistoryItem } from "@shared/HistoryItem"
import { Logger } from "@shared/services/Logger"
import type { Settings } from "@shared/storage/state-keys"
import type { Mode } from "@shared/storage/types"
import { StateManager } from "@/core/storage/StateManager"
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
import { getProviderSettingsManager } from "./provider-migration"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Configuration for creating a new session */
export interface SessionConfigInput {
/** The user's prompt */
prompt?: string
/** Images attached to the message */
images?: string[]
/** Files attached to the message */
files?: string[]
/** History item to resume (for task resumption) */
historyItem?: HistoryItem
/** Task-specific settings overrides */
taskSettings?: Partial<Settings>
/** Working directory */
cwd: string
/** Workspace root */
workspaceRoot?: string
/** Current mode (act/plan) */
mode?: Mode
}
/** Active session state tracked by the factory */
export interface ActiveSession {
/** The session ID */
sessionId: string
/** The SessionManager instance managing this session (VscodeSessionHost) */
sessionManager: SessionManager
/** Unsubscribe function for session events */
unsubscribe: () => void
/** The start result from the session */
startResult?: StartSessionResult
/** Whether the session is currently running */
isRunning: boolean
}
// ---------------------------------------------------------------------------
// Provider → API key field mapping
// ---------------------------------------------------------------------------
/**
* Maps a provider ID to the corresponding API key field name in ApiConfiguration.
* This covers all 30+ providers supported by the classic extension.
*/
const PROVIDER_API_KEY_MAP: Record<string, keyof ApiConfiguration> = {
anthropic: "apiKey",
openrouter: "openRouterApiKey",
openai: "openAiApiKey",
"openai-native": "openAiNativeApiKey",
"openai-codex": "openAiNativeApiKey", // Codex uses the same key
bedrock: "awsBedrockApiKey",
vertex: "geminiApiKey",
gemini: "geminiApiKey",
deepseek: "deepSeekApiKey",
ollama: "ollamaApiKey",
lmstudio: "apiKey", // LM Studio doesn't need a key but uses the generic field
requesty: "requestyApiKey",
together: "togetherApiKey",
fireworks: "fireworksApiKey",
qwen: "qwenApiKey",
doubao: "doubaoApiKey",
mistral: "mistralApiKey",
litellm: "liteLlmApiKey",
asksage: "asksageApiKey",
xai: "xaiApiKey",
moonshot: "moonshotApiKey",
zai: "zaiApiKey",
huggingface: "huggingFaceApiKey",
nebius: "nebiusApiKey",
sambanova: "sambanovaApiKey",
cerebras: "cerebrasApiKey",
groq: "groqApiKey",
baseten: "basetenApiKey",
"huawei-cloud-maas": "huaweiCloudMaasApiKey",
dify: "difyApiKey",
minimax: "minimaxApiKey",
hicap: "hicapApiKey",
aihubmix: "aihubmixApiKey",
nousResearch: "nousResearchApiKey",
"vercel-ai-gateway": "vercelAiGatewayApiKey",
sapaicore: "sapAiCoreClientId", // SAP uses client ID + secret
claude_code: "apiKey", // Claude Code uses anthropic key
wandb: "wandbApiKey",
"qwen-code": "qwenApiKey",
oca: "ocaApiKey",
// "cline" is handled specially — see resolveApiKey()
}
/**
* Maps a provider ID to the mode-specific model ID field name in ApiConfiguration.
* For providers that have dedicated model ID fields per mode.
*/
const PROVIDER_MODEL_ID_MAP: Record<string, { plan: keyof ApiConfiguration; act: keyof ApiConfiguration }> = {
anthropic: { plan: "planModeApiModelId", act: "actModeApiModelId" },
openrouter: { plan: "planModeOpenRouterModelId", act: "actModeOpenRouterModelId" },
openai: { plan: "planModeOpenAiModelId", act: "actModeOpenAiModelId" },
"openai-native": { plan: "planModeApiModelId", act: "actModeApiModelId" },
"openai-codex": { plan: "planModeApiModelId", act: "actModeApiModelId" },
ollama: { plan: "planModeOllamaModelId", act: "actModeOllamaModelId" },
lmstudio: { plan: "planModeLmStudioModelId", act: "actModeLmStudioModelId" },
gemini: { plan: "planModeApiModelId", act: "actModeApiModelId" },
bedrock: { plan: "planModeApiModelId", act: "actModeApiModelId" },
vertex: { plan: "planModeApiModelId", act: "actModeApiModelId" },
deepseek: { plan: "planModeApiModelId", act: "actModeApiModelId" },
cline: { plan: "planModeClineModelId", act: "actModeClineModelId" },
litellm: { plan: "planModeLiteLlmModelId", act: "actModeLiteLlmModelId" },
requesty: { plan: "planModeRequestyModelId", act: "actModeRequestyModelId" },
together: { plan: "planModeTogetherModelId", act: "actModeTogetherModelId" },
fireworks: { plan: "planModeFireworksModelId", act: "actModeFireworksModelId" },
groq: { plan: "planModeGroqModelId", act: "actModeGroqModelId" },
baseten: { plan: "planModeBasetenModelId", act: "actModeBasetenModelId" },
huggingface: { plan: "planModeHuggingFaceModelId", act: "actModeHuggingFaceModelId" },
"huawei-cloud-maas": { plan: "planModeHuaweiCloudMaasModelId", act: "actModeHuaweiCloudMaasModelId" },
oca: { plan: "planModeOcaModelId", act: "actModeOcaModelId" },
aihubmix: { plan: "planModeAihubmixModelId", act: "actModeAihubmixModelId" },
hicap: { plan: "planModeHicapModelId", act: "actModeHicapModelId" },
nousResearch: { plan: "planModeNousResearchModelId", act: "actModeNousResearchModelId" },
"vercel-ai-gateway": { plan: "planModeVercelAiGatewayModelId", act: "actModeVercelAiGatewayModelId" },
sapaicore: { plan: "planModeSapAiCoreModelId", act: "actModeSapAiCoreModelId" },
}
// ---------------------------------------------------------------------------
// API key resolution
// ---------------------------------------------------------------------------
/**
* Resolve the API key for a given provider from the ApiConfiguration.
*
* For the "cline" provider, reads the OAuth token from providers.json
* via ProviderSettingsManager (the single source of truth for credentials).
*/
function resolveApiKey(providerId: string, config: ApiConfiguration): string | undefined {
// For "cline" provider — read from providers.json
if (providerId === "cline") {
// First check if clineApiKey is set directly (e.g. from env var)
if (config.clineApiKey) {
return config.clineApiKey
}
// Read from providers.json via the shared ProviderSettingsManager
try {
const manager = getProviderSettingsManager()
const settings = manager.getProviderSettings("cline")
const accessToken = settings?.auth?.accessToken?.trim()
if (accessToken) {
// providers.json stores the token with workos: prefix already
return accessToken.toLowerCase().startsWith("workos:") ? accessToken : `workos:${accessToken}`
}
} catch {
Logger.warn("[SessionFactory] Failed to read cline credentials from providers.json")
}
return undefined
}
// For all other providers, look up the API key field name
const keyField = PROVIDER_API_KEY_MAP[providerId]
if (keyField) {
const apiKey = config[keyField] as string | undefined
if (apiKey) {
return apiKey
}
}
return undefined
}
/**
* Resolve the model ID for a given provider and mode from the ApiConfiguration.
* Uses mode-specific model ID fields when available, falls back to generic fields.
*/
function resolveModelId(providerId: string, mode: Mode, config: ApiConfiguration): string | undefined {
// Check provider-specific mode model ID fields
const modelFields = PROVIDER_MODEL_ID_MAP[providerId]
if (modelFields) {
const field = mode === "plan" ? modelFields.plan : modelFields.act
const modelId = config[field] as string | undefined
if (modelId) {
return modelId
}
}
// Fallback to generic mode model ID fields
const genericField = mode === "plan" ? "planModeApiModelId" : "actModeApiModelId"
const genericModelId = config[genericField] as string | undefined
if (genericModelId) {
return genericModelId
}
return undefined
}
/**
* Resolve the base URL for a given provider from the ApiConfiguration.
*/
function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
const baseUrlMap: Record<string, keyof ApiConfiguration> = {
anthropic: "anthropicBaseUrl",
openai: "openAiBaseUrl",
ollama: "ollamaBaseUrl",
lmstudio: "lmStudioBaseUrl",
gemini: "geminiBaseUrl",
requesty: "requestyBaseUrl",
litellm: "liteLlmBaseUrl",
oca: "ocaBaseUrl",
aihubmix: "aihubmixBaseUrl",
dify: "difyBaseUrl",
}
const field = baseUrlMap[providerId]
if (field) {
return config[field] as string | undefined
}
return undefined
}
// ---------------------------------------------------------------------------
// Session config builder
// ---------------------------------------------------------------------------
/**
* Build a CoreSessionConfig from the current state.
*
* Reads provider settings from the classic StateManager's ApiConfiguration
* (which correctly reads from globalState.json + secrets.json), then resolves
* the provider, model, and API key for the current mode (plan/act).
*
* This replaces the previous two-path approach (SDK ProviderSettingsManager +
* StateManager.buildApiHandlerSettings) which both failed silently.
*/
export async function buildSessionConfig(input: SessionConfigInput): Promise<CoreSessionConfig> {
const cwd = input.cwd || process.cwd()
const workspaceRoot = input.workspaceRoot ?? cwd
const mode: Mode = input.mode ?? "act"
let providerId: string | undefined
let modelId: string | undefined
let apiKey: string | undefined
let baseUrl: string | undefined
try {
const stateManager = StateManager.get()
const apiConfig = stateManager.getApiConfiguration()
// Resolve the provider for the current mode
const modeProvider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
providerId = modeProvider
if (providerId) {
// Resolve API key
apiKey = resolveApiKey(providerId, apiConfig)
// Resolve model ID
modelId = resolveModelId(providerId, mode, apiConfig)
// Resolve base URL
baseUrl = resolveBaseUrl(providerId, apiConfig)
Logger.log(
`[SessionFactory] Resolved from StateManager: provider=${providerId}, model=${modelId}, hasApiKey=${!!apiKey}`,
)
}
} catch (error) {
Logger.warn("[SessionFactory] StateManager credential resolution failed:", error)
}
// Fallback: try SDK's ProviderSettingsManager if StateManager didn't yield results
if (!providerId || !apiKey) {
try {
const dataDir = resolveDataDir()
const manager = getProviderSettingsManager(dataDir)
const lastUsed = manager.getLastUsedProviderSettings()
if (lastUsed?.provider && lastUsed?.apiKey) {
providerId = lastUsed.provider
modelId = lastUsed.model
apiKey = lastUsed.apiKey
baseUrl = lastUsed.baseUrl
Logger.log(`[SessionFactory] Using SDK provider fallback: ${providerId}/${modelId}`)
}
} catch (error) {
Logger.warn("[SessionFactory] SDK ProviderSettingsManager fallback failed:", error)
}
}
// Final defaults
providerId = providerId ?? "anthropic"
modelId = modelId ?? "claude-sonnet-4-6"
apiKey = apiKey ?? ""
// Build the system prompt using the SDK's prompt builder.
// This is required — the SDK does NOT have a fallback for empty system prompts.
// Both the CLI (apps/cli/src/runtime/prompt.ts) and the SDK's VSCode extension
// (apps/vscode/src/extension.ts) call buildClineSystemPrompt() before passing
// the config to the session manager.
let systemPrompt = ""
try {
const { basename } = await import("path")
const metadata = await buildWorkspaceMetadata(cwd)
systemPrompt = buildClineSystemPrompt({
ide: "VS Code",
workspaceRoot: cwd,
workspaceName: basename(cwd),
metadata,
mode: mode === "plan" ? "plan" : "act",
providerId,
platform: process.platform,
})
Logger.log(`[SessionFactory] Built system prompt: ${systemPrompt.length} chars`)
} catch (error) {
Logger.warn("[SessionFactory] Failed to build system prompt, using minimal fallback:", error)
systemPrompt = "You are Cline, a highly skilled software engineer. Help the user with their request."
}
const config: CoreSessionConfig = {
providerId,
modelId,
apiKey,
baseUrl,
cwd,
workspaceRoot,
systemPrompt,
enableTools: true,
enableSpawnAgent: input.taskSettings?.subagentsEnabled ?? false,
enableAgentTeams: false,
mode: mode === "plan" ? "plan" : "act",
thinking: false,
maxIterations: undefined,
}
return config
}
// ---------------------------------------------------------------------------
// Session factory
// ---------------------------------------------------------------------------
/**
* Build the StartSessionInput for a new task.
*
* IMPORTANT: We pass `interactive: true` but NO `prompt`. This creates the
* session and returns immediately — the SDK's DefaultSessionManager.start()
* checks `if (startInput.prompt?.trim())` and skips `runTurn()` when there's
* no prompt. The caller should then call `core.send({ sessionId, prompt })`
* to run the first turn. This cleanly separates session creation from
* inference, preventing the gRPC handler from blocking until the first
* agent turn completes.
*/
export function buildStartSessionInput(config: CoreSessionConfig, input: SessionConfigInput): StartSessionInput {
return {
config,
// Do NOT pass prompt here — start() should return immediately.
// The prompt is sent separately via core.send() after session creation.
prompt: undefined,
interactive: true, // VSCode extension always uses interactive mode
userImages: input.images,
userFiles: input.files,
}
}
/**
* Build the StartSessionInput for resuming an existing task.
*
* When resuming, we don't pass initialMessages — the SDK's session
* persistence handles loading the conversation history from disk.
*/
export function buildResumeSessionInput(
sessionId: string,
prompt: string,
images?: string[],
files?: string[],
): { sessionId: string; prompt: string; userImages?: string[]; userFiles?: string[] } {
return {
sessionId,
prompt,
userImages: images,
userFiles: files,
}
}
// ---------------------------------------------------------------------------
// Task history helpers
// ---------------------------------------------------------------------------
/**
* Get a HistoryItem by ID from the task history.
*/
export function getHistoryItemById(taskId: string, dataDir?: string): HistoryItem | undefined {
const history = readTaskHistory(dataDir)
return history.find((item) => item.id === taskId)
}
/**
* Update a HistoryItem in the task history.
* Returns the updated history array.
*/
export function updateHistoryItem(item: HistoryItem, dataDir?: string): HistoryItem[] {
// This will be properly implemented when we wire up the gRPC handlers
// in Step 5. For now, we read the history, update the item, and return it.
const history = readTaskHistory(dataDir)
const index = history.findIndex((h) => h.id === item.id)
if (index >= 0) {
history[index] = item
} else {
history.unshift(item)
}
return history
}
/**
* Create a new HistoryItem from a session start result.
*/
export function createHistoryItemFromSession(sessionId: string, prompt: string, modelId?: string, cwd?: string): HistoryItem {
return {
id: sessionId,
ts: Date.now(),
task: prompt,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
modelId,
cwdOnTaskInitialization: cwd,
}
}
+19
View File
@@ -0,0 +1,19 @@
// SDK Adapter Layer
// Replaces classic src/core/controller/ (see origin/main)
//
// This module provides the SDK-backed Controller and related adapters.
// The webview continues to communicate via gRPC; this layer translates
// between gRPC handlers and SDK calls.
export * from "./account-service"
export * from "./auth-service"
export * from "./cline-session-factory"
export * from "./legacy-state-reader"
export * from "./message-translator"
export * from "./provider-migration"
export type { SessionEventListener } from "./SdkController"
export { Controller } from "./SdkController"
export * from "./task-proxy"
export * from "./vscode-runtime-builder"
export * from "./vscode-session-host"
export * from "./webview-grpc-bridge"
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest"
import { sanitizeInitialMessagesForSessionStart } from "./initial-message-sanitizer"
describe("sanitizeInitialMessagesForSessionStart", () => {
it("returns original array when no tool_use blocks exist", () => {
const input = [
{ role: "user", content: "hello" },
{ role: "assistant", content: "hi" },
]
const result = sanitizeInitialMessagesForSessionStart(input)
expect(result).toBe(input)
})
it("adds missing tool_result blocks to the next user message", () => {
const input = [
{
role: "assistant",
content: [{ type: "tool_use", id: "toolu_1", name: "read_file", input: { path: "a.ts" } }],
},
{
role: "user",
content: [{ type: "text", text: "continue" }],
},
]
const result = sanitizeInitialMessagesForSessionStart(input)
expect(result).not.toBe(input)
const nextContent = (result[1] as { content: Array<Record<string, unknown>> }).content
expect(nextContent).toEqual([
expect.objectContaining({
type: "tool_result",
tool_use_id: "toolu_1",
}),
])
expect(result[2]).toMatchObject({
role: "user",
content: [expect.objectContaining({ type: "text", text: "continue" })],
})
})
it("inserts synthetic user tool_result message when missing next user message", () => {
const input = [
{
role: "assistant",
content: [{ type: "tool_use", id: "toolu_1", name: "read_file", input: { path: "a.ts" } }],
},
{ role: "assistant", content: [{ type: "text", text: "extra" }] },
]
const result = sanitizeInitialMessagesForSessionStart(input)
expect(result).toHaveLength(3)
expect(result[1]).toMatchObject({
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_1" }],
})
})
it("reorders existing tool_result blocks to match tool_use order", () => {
const input = [
{
role: "assistant",
content: [
{ type: "tool_use", id: "toolu_1", name: "read_file", input: { path: "a.ts" } },
{ type: "tool_use", id: "toolu_2", name: "read_file", input: { path: "b.ts" } },
],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_2", content: "b" },
{ type: "text", text: "keep me" },
{ type: "tool_result", tool_use_id: "toolu_1", content: "a" },
],
},
]
const result = sanitizeInitialMessagesForSessionStart(input)
const nextContent = (result[1] as { content: Array<Record<string, unknown>> }).content
expect(nextContent[0]).toMatchObject({ type: "tool_result", tool_use_id: "toolu_1" })
expect(nextContent[1]).toMatchObject({ type: "tool_result", tool_use_id: "toolu_2" })
expect(nextContent[2]).toMatchObject({ type: "text", text: "keep me" })
})
})
+143
View File
@@ -0,0 +1,143 @@
type GenericContentBlock = Record<string, unknown>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function toBlocks(content: unknown): GenericContentBlock[] {
if (Array.isArray(content)) {
return content.filter(isRecord)
}
if (typeof content === "string") {
return [{ type: "text", text: content }]
}
return []
}
function getToolUseIds(content: unknown): string[] {
if (!Array.isArray(content)) {
return []
}
const ids: string[] = []
for (const block of content) {
if (!isRecord(block)) {
continue
}
if (block.type === "tool_use" && typeof block.id === "string") {
ids.push(block.id)
}
}
return ids
}
function isToolResultForId(block: GenericContentBlock, toolUseId: string): boolean {
return block.type === "tool_result" && block.tool_use_id === toolUseId
}
const MIGRATION_MISSING_TOOL_RESULT_TEXT = "[migration] Tool result missing in legacy conversation history."
function createMissingToolResult(toolUseId: string): GenericContentBlock {
return {
type: "tool_result",
tool_use_id: toolUseId,
content: MIGRATION_MISSING_TOOL_RESULT_TEXT,
}
}
function isMigrationPlaceholderToolResult(block: GenericContentBlock): boolean {
return block.type === "tool_result" && block.content === MIGRATION_MISSING_TOOL_RESULT_TEXT
}
/**
* Ensures pre-SDK conversation messages satisfy strict tool-use pairing rules.
*
* SDK runtime validation expects every assistant tool_use block to have matching
* tool_result blocks at the start of the following user message. Legacy
* conversations (especially interrupted turns) can miss these blocks, causing
* "Tool result is missing for tool call ..." errors on resume.
*/
export function sanitizeInitialMessagesForSessionStart(messages: unknown[]): unknown[] {
if (messages.length === 0) {
return messages
}
const sanitized = [...messages]
let changed = false
for (let i = 0; i < sanitized.length; i++) {
const assistantMessage = sanitized[i]
if (!isRecord(assistantMessage) || assistantMessage.role !== "assistant") {
continue
}
const toolUseIds = getToolUseIds(assistantMessage.content)
if (toolUseIds.length === 0) {
continue
}
const next = sanitized[i + 1]
if (!isRecord(next) || next.role !== "user") {
// Insert a synthetic user message with placeholder tool results so
// the message stream remains valid for SDK parsing.
sanitized.splice(i + 1, 0, {
role: "user",
content: toolUseIds.map(createMissingToolResult),
})
changed = true
i += 1
continue
}
const originalBlocks = toBlocks(next.content)
const matchingToolResults = new Map<string, GenericContentBlock>()
for (const block of originalBlocks) {
for (const toolUseId of toolUseIds) {
if (!matchingToolResults.has(toolUseId) && isToolResultForId(block, toolUseId)) {
matchingToolResults.set(toolUseId, block)
}
}
}
const missingToolResultIds = toolUseIds.filter((toolUseId) => !matchingToolResults.has(toolUseId))
const orderedToolResults = toolUseIds.map(
(toolUseId) => matchingToolResults.get(toolUseId) ?? createMissingToolResult(toolUseId),
)
const otherBlocks = originalBlocks.filter((block) => !toolUseIds.some((toolUseId) => isToolResultForId(block, toolUseId)))
// If we had to synthesize missing tool_result blocks (or are carrying the
// migration placeholder from a previous resume attempt), keep the immediate
// response message strictly tool_result-only for maximum provider compatibility.
// Move any existing non-tool-result content into a follow-up user message.
const hasMigrationPlaceholder = orderedToolResults.some(isMigrationPlaceholderToolResult)
if (missingToolResultIds.length > 0 || hasMigrationPlaceholder) {
sanitized[i + 1] = {
...next,
content: orderedToolResults,
}
if (otherBlocks.length > 0) {
sanitized.splice(i + 2, 0, {
role: "user",
content: otherBlocks,
})
i += 1
}
changed = true
continue
}
const newContent = [...orderedToolResults, ...otherBlocks]
const differsInLength = newContent.length !== originalBlocks.length
const differsInOrder = !differsInLength && newContent.some((block, index) => block !== originalBlocks[index])
if (differsInLength || differsInOrder) {
sanitized[i + 1] = {
...next,
content: newContent,
}
changed = true
}
}
return changed ? sanitized : messages
}
+463
View File
@@ -0,0 +1,463 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import {
listTaskIds,
type McpSettingsFile,
readAllLegacyState,
readApiConversationHistory,
readContextHistory,
readGlobalState,
readGlobalStateKey,
readMcpSettings,
readSecretKey,
readSecrets,
readTaskHistory,
readTaskMetadata,
readUiMessages,
resolveDataDir,
} from "./legacy-state-reader"
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-legacy-state-"))
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
function writeJson(filePath: string, data: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, JSON.stringify(data, null, 2))
}
// ---------------------------------------------------------------------------
// resolveDataDir
// ---------------------------------------------------------------------------
describe("resolveDataDir", () => {
it("uses override when provided", () => {
expect(resolveDataDir("/custom/path")).toBe("/custom/path")
})
it("falls back to CLINE_DATA_DIR env", () => {
const original = process.env.CLINE_DATA_DIR
process.env.CLINE_DATA_DIR = "/env/data"
try {
expect(resolveDataDir()).toBe("/env/data")
} finally {
process.env.CLINE_DATA_DIR = original
}
})
it("falls back to CLINE_DIR/data", () => {
const originalData = process.env.CLINE_DATA_DIR
const originalDir = process.env.CLINE_DIR
delete process.env.CLINE_DATA_DIR
process.env.CLINE_DIR = "/cline"
try {
expect(resolveDataDir()).toBe("/cline/data")
} finally {
process.env.CLINE_DATA_DIR = originalData
process.env.CLINE_DIR = originalDir
}
})
it("falls back to ~/.cline/data", () => {
const originalData = process.env.CLINE_DATA_DIR
const originalDir = process.env.CLINE_DIR
delete process.env.CLINE_DATA_DIR
delete process.env.CLINE_DIR
try {
expect(resolveDataDir()).toBe(path.join(os.homedir(), ".cline", "data"))
} finally {
process.env.CLINE_DATA_DIR = originalData
process.env.CLINE_DIR = originalDir
}
})
})
// ---------------------------------------------------------------------------
// readGlobalState
// ---------------------------------------------------------------------------
describe("readGlobalState", () => {
it("returns empty object when file is missing", () => {
expect(readGlobalState(tempDir)).toEqual({})
})
it("reads globalState.json contents", () => {
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "anthropic",
actModeApiModelId: "claude-sonnet-4-6",
telemetrySetting: "enabled",
})
const state = readGlobalState(tempDir)
expect(state.mode).toBe("act")
expect(state.actModeApiProvider).toBe("anthropic")
expect(state.actModeApiModelId).toBe("claude-sonnet-4-6")
expect(state.telemetrySetting).toBe("enabled")
})
it("returns empty object for corrupt JSON", () => {
const filePath = path.join(tempDir, "globalState.json")
fs.mkdirSync(tempDir, { recursive: true })
fs.writeFileSync(filePath, "NOT VALID JSON{{{")
expect(readGlobalState(tempDir)).toEqual({})
})
it("returns empty object for empty file", () => {
const filePath = path.join(tempDir, "globalState.json")
fs.mkdirSync(tempDir, { recursive: true })
fs.writeFileSync(filePath, "")
expect(readGlobalState(tempDir)).toEqual({})
})
it("returns empty object for {} file", () => {
writeJson(path.join(tempDir, "globalState.json"), {})
expect(readGlobalState(tempDir)).toEqual({})
})
})
// ---------------------------------------------------------------------------
// readGlobalStateKey
// ---------------------------------------------------------------------------
describe("readGlobalStateKey", () => {
it("returns undefined for missing key", () => {
writeJson(path.join(tempDir, "globalState.json"), { mode: "act" })
expect(readGlobalStateKey("telemetrySetting", tempDir)).toBeUndefined()
})
it("returns value for present key", () => {
writeJson(path.join(tempDir, "globalState.json"), { mode: "plan" })
expect(readGlobalStateKey("mode", tempDir)).toBe("plan")
})
})
// ---------------------------------------------------------------------------
// readSecrets
// ---------------------------------------------------------------------------
describe("readSecrets", () => {
it("returns empty object when file is missing", () => {
expect(readSecrets(tempDir)).toEqual({})
})
it("reads secrets.json contents", () => {
writeJson(path.join(tempDir, "secrets.json"), {
apiKey: "sk-ant-test123",
openRouterApiKey: "sk-or-test456",
})
const secrets = readSecrets(tempDir)
expect(secrets.apiKey).toBe("sk-ant-test123")
expect(secrets.openRouterApiKey).toBe("sk-or-test456")
})
it("returns empty object for corrupt JSON", () => {
const filePath = path.join(tempDir, "secrets.json")
fs.mkdirSync(tempDir, { recursive: true })
fs.writeFileSync(filePath, "BROKEN{")
expect(readSecrets(tempDir)).toEqual({})
})
})
// ---------------------------------------------------------------------------
// readSecretKey
// ---------------------------------------------------------------------------
describe("readSecretKey", () => {
it("returns undefined for missing key", () => {
writeJson(path.join(tempDir, "secrets.json"), { apiKey: "test" })
expect(readSecretKey("openRouterApiKey", tempDir)).toBeUndefined()
})
it("returns value for present key", () => {
writeJson(path.join(tempDir, "secrets.json"), { apiKey: "sk-test" })
expect(readSecretKey("apiKey", tempDir)).toBe("sk-test")
})
})
// ---------------------------------------------------------------------------
// readTaskHistory
// ---------------------------------------------------------------------------
describe("readTaskHistory", () => {
it("returns empty array when file is missing", () => {
expect(readTaskHistory(tempDir)).toEqual([])
})
it("reads taskHistory.json from state/ subdirectory", () => {
const history = [
{
id: "task-1",
ts: Date.now(),
task: "Hello world",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
},
{
id: "task-2",
ts: Date.now() + 1000,
task: "Second task",
tokensIn: 200,
tokensOut: 100,
totalCost: 0.02,
isFavorited: true,
},
]
writeJson(path.join(tempDir, "state", "taskHistory.json"), history)
const result = readTaskHistory(tempDir)
expect(result).toHaveLength(2)
expect(result[0].id).toBe("task-1")
expect(result[1].isFavorited).toBe(true)
})
it("returns empty array for corrupt JSON", () => {
const filePath = path.join(tempDir, "state", "taskHistory.json")
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, "INVALID")
expect(readTaskHistory(tempDir)).toEqual([])
})
})
// ---------------------------------------------------------------------------
// readApiConversationHistory
// ---------------------------------------------------------------------------
describe("readApiConversationHistory", () => {
it("returns empty array when file is missing", () => {
expect(readApiConversationHistory("task-1", tempDir)).toEqual([])
})
it("reads api_conversation_history.json", () => {
const history = [
{ role: "user", content: [{ type: "text", text: "Hello" }] },
{ role: "assistant", content: [{ type: "text", text: "Hi there" }] },
]
writeJson(path.join(tempDir, "tasks", "task-1", "api_conversation_history.json"), history)
const result = readApiConversationHistory("task-1", tempDir)
expect(result).toHaveLength(2)
expect(result[0].role).toBe("user")
expect(result[1].role).toBe("assistant")
})
})
// ---------------------------------------------------------------------------
// readUiMessages
// ---------------------------------------------------------------------------
describe("readUiMessages", () => {
it("returns empty array when file is missing", () => {
expect(readUiMessages("task-1", tempDir)).toEqual([])
})
it("reads ui_messages.json", () => {
const messages = [
{ type: "say", say: "text", text: "Hello" },
{ type: "ask", ask: "tool", text: "Run command?" },
]
writeJson(path.join(tempDir, "tasks", "task-1", "ui_messages.json"), messages)
const result = readUiMessages("task-1", tempDir)
expect(result).toHaveLength(2)
})
})
// ---------------------------------------------------------------------------
// readContextHistory
// ---------------------------------------------------------------------------
describe("readContextHistory", () => {
it("returns empty array when file is missing", () => {
expect(readContextHistory("task-1", tempDir)).toEqual([])
})
it("reads context_history.json", () => {
const history = [{ context: "test" }]
writeJson(path.join(tempDir, "tasks", "task-1", "context_history.json"), history)
const result = readContextHistory("task-1", tempDir)
expect(result).toHaveLength(1)
})
})
// ---------------------------------------------------------------------------
// readTaskMetadata
// ---------------------------------------------------------------------------
describe("readTaskMetadata", () => {
it("returns empty object when file is missing", () => {
expect(readTaskMetadata("task-1", tempDir)).toEqual({})
})
it("reads task_metadata.json", () => {
const metadata = { files_in_context: ["src/index.ts"], model_usage: [] }
writeJson(path.join(tempDir, "tasks", "task-1", "task_metadata.json"), metadata)
const result = readTaskMetadata("task-1", tempDir)
expect(result.files_in_context).toEqual(["src/index.ts"])
})
})
// ---------------------------------------------------------------------------
// readMcpSettings
// ---------------------------------------------------------------------------
describe("readMcpSettings", () => {
it("returns empty mcpServers when file is missing", () => {
expect(readMcpSettings(tempDir)).toEqual({ mcpServers: {} })
})
it("reads cline_mcp_settings.json from settings/ subdirectory", () => {
const settings: McpSettingsFile = {
mcpServers: {
"my-server": {
command: "node",
args: ["server.js"],
env: { API_KEY: "test" },
},
"remote-server": {
url: "https://mcp.example.com/sse",
transport: "sse",
disabled: true,
},
},
}
writeJson(path.join(tempDir, "settings", "cline_mcp_settings.json"), settings)
const result = readMcpSettings(tempDir)
expect(Object.keys(result.mcpServers)).toHaveLength(2)
expect(result.mcpServers["my-server"].command).toBe("node")
expect(result.mcpServers["remote-server"].url).toBe("https://mcp.example.com/sse")
expect(result.mcpServers["remote-server"].disabled).toBe(true)
})
it("returns empty mcpServers for corrupt JSON", () => {
const filePath = path.join(tempDir, "settings", "cline_mcp_settings.json")
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, "NOT JSON")
expect(readMcpSettings(tempDir)).toEqual({ mcpServers: {} })
})
})
// ---------------------------------------------------------------------------
// listTaskIds
// ---------------------------------------------------------------------------
describe("listTaskIds", () => {
it("returns empty array when tasks directory is missing", () => {
expect(listTaskIds(tempDir)).toEqual([])
})
it("lists task directories", () => {
fs.mkdirSync(path.join(tempDir, "tasks", "task-1"), { recursive: true })
fs.mkdirSync(path.join(tempDir, "tasks", "task-2"), { recursive: true })
// Create a file — should not be listed
fs.writeFileSync(path.join(tempDir, "tasks", "not-a-dir.txt"), "test")
const ids = listTaskIds(tempDir)
expect(ids).toContain("task-1")
expect(ids).toContain("task-2")
expect(ids).not.toContain("not-a-dir.txt")
})
})
// ---------------------------------------------------------------------------
// readAllLegacyState
// ---------------------------------------------------------------------------
describe("readAllLegacyState", () => {
it("returns defaults when no files exist", () => {
const state = readAllLegacyState(tempDir)
expect(state.globalState).toEqual({})
expect(state.secrets).toEqual({})
expect(state.taskHistory).toEqual([])
expect(state.mcpSettings).toEqual({ mcpServers: {} })
})
it("reads all state files at once", () => {
// Write all the files
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "anthropic",
})
writeJson(path.join(tempDir, "secrets.json"), {
apiKey: "sk-ant-test",
})
writeJson(path.join(tempDir, "state", "taskHistory.json"), [
{ id: "task-1", ts: Date.now(), task: "Test", tokensIn: 0, tokensOut: 0, totalCost: 0 },
])
writeJson(path.join(tempDir, "settings", "cline_mcp_settings.json"), {
mcpServers: {
"test-server": { command: "node", args: ["mcp.js"] },
},
})
const state = readAllLegacyState(tempDir)
expect(state.globalState.mode).toBe("act")
expect(state.globalState.actModeApiProvider).toBe("anthropic")
expect(state.secrets.apiKey).toBe("sk-ant-test")
expect(state.taskHistory).toHaveLength(1)
expect(state.taskHistory[0].id).toBe("task-1")
expect(state.mcpSettings.mcpServers["test-server"].command).toBe("node")
})
it("handles partial state (some files missing)", () => {
// Only write globalState
writeJson(path.join(tempDir, "globalState.json"), { mode: "plan" })
const state = readAllLegacyState(tempDir)
expect(state.globalState.mode).toBe("plan")
expect(state.secrets).toEqual({})
expect(state.taskHistory).toEqual([])
expect(state.mcpSettings).toEqual({ mcpServers: {} })
})
})
// ---------------------------------------------------------------------------
// Error handling edge cases
// ---------------------------------------------------------------------------
describe("error handling", () => {
it("handles unreadable files gracefully", () => {
const filePath = path.join(tempDir, "globalState.json")
fs.mkdirSync(tempDir, { recursive: true })
fs.writeFileSync(filePath, "valid json initially")
// Make file unreadable (on POSIX)
if (process.platform !== "win32") {
fs.chmodSync(filePath, 0o000)
// Should not throw, returns fallback
expect(readGlobalState(tempDir)).toEqual({})
// Restore permissions for cleanup
fs.chmodSync(filePath, 0o644)
}
})
it("handles whitespace-only files", () => {
const filePath = path.join(tempDir, "globalState.json")
fs.mkdirSync(tempDir, { recursive: true })
fs.writeFileSync(filePath, " \n \t ")
expect(readGlobalState(tempDir)).toEqual({})
})
})
+278
View File
@@ -0,0 +1,278 @@
// Replaces classic src/core/storage/disk.ts reads (see origin/main)
//
// Reads all existing on-disk state from the Cline data directory.
// This module is used by the SDK adapter layer to bootstrap state
// from the classic storage format during migration.
//
// All reads are non-throwing — missing or corrupt files return defaults.
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineMessage } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { Logger } from "@shared/services/Logger"
import { GlobalStateAndSettings, Secrets } from "@shared/storage/state-keys"
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
/**
* Resolve the Cline data directory.
* Priority: CLINE_DATA_DIR env > CLINE_DIR env + "/data" > ~/.cline/data
*/
export function resolveDataDir(override?: string): string {
if (override) return override
if (process.env.CLINE_DATA_DIR) return process.env.CLINE_DATA_DIR
const clineDir = process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
return path.join(clineDir, "data")
}
/** Path to globalState.json */
export function globalStatePath(dataDir?: string): string {
return path.join(resolveDataDir(dataDir), "globalState.json")
}
/** Path to secrets.json */
export function secretsPath(dataDir?: string): string {
return path.join(resolveDataDir(dataDir), "secrets.json")
}
/** Path to taskHistory.json (stored in state/ subdirectory) */
export function taskHistoryPath(dataDir?: string): string {
return path.join(resolveDataDir(dataDir), "state", "taskHistory.json")
}
/** Path to MCP settings file */
export function mcpSettingsPath(dataDir?: string): string {
return path.join(resolveDataDir(dataDir), "settings", "cline_mcp_settings.json")
}
/** Path to a task directory */
export function taskDirPath(taskId: string, dataDir?: string): string {
return path.join(resolveDataDir(dataDir), "tasks", taskId)
}
/** Path to api_conversation_history.json for a task */
export function apiConversationHistoryPath(taskId: string, dataDir?: string): string {
return path.join(taskDirPath(taskId, dataDir), "api_conversation_history.json")
}
/** Path to ui_messages.json for a task */
export function uiMessagesPath(taskId: string, dataDir?: string): string {
return path.join(taskDirPath(taskId, dataDir), "ui_messages.json")
}
/** Path to context_history.json for a task */
export function contextHistoryPath(taskId: string, dataDir?: string): string {
return path.join(taskDirPath(taskId, dataDir), "context_history.json")
}
/** Path to task_metadata.json for a task */
export function taskMetadataPath(taskId: string, dataDir?: string): string {
return path.join(taskDirPath(taskId, dataDir), "task_metadata.json")
}
// ---------------------------------------------------------------------------
// Low-level JSON reader
// ---------------------------------------------------------------------------
/**
* Read and parse a JSON file, returning undefined on any error.
* Never throws — returns fallback instead.
*/
function readJsonFile<T>(filePath: string, fallback: T): T {
try {
if (!fs.existsSync(filePath)) {
return fallback
}
const content = fs.readFileSync(filePath, "utf-8").trim()
if (!content || content === "{}") {
return fallback
}
return JSON.parse(content) as T
} catch (error) {
Logger.warn(`[LegacyStateReader] Failed to read ${filePath}:`, error)
return fallback
}
}
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
/**
* Read the full globalState.json contents.
* Returns a partial record — only keys present on disk are included.
*/
export function readGlobalState(dataDir?: string): Partial<GlobalStateAndSettings> {
return readJsonFile<Partial<GlobalStateAndSettings>>(globalStatePath(dataDir), {})
}
/**
* Read a single key from globalState.json.
*/
export function readGlobalStateKey<K extends keyof GlobalStateAndSettings>(
key: K,
dataDir?: string,
): GlobalStateAndSettings[K] | undefined {
const state = readGlobalState(dataDir)
return state[key]
}
// ---------------------------------------------------------------------------
// Secrets
// ---------------------------------------------------------------------------
/**
* Read the full secrets.json contents.
* Returns a partial record — only keys present on disk are included.
*/
export function readSecrets(dataDir?: string): Partial<Secrets> {
return readJsonFile<Partial<Secrets>>(secretsPath(dataDir), {})
}
/**
* Read a single key from secrets.json.
*/
export function readSecretKey<K extends keyof Secrets>(key: K, dataDir?: string): Secrets[K] | undefined {
const secrets = readSecrets(dataDir)
return secrets[key]
}
// ---------------------------------------------------------------------------
// Task history
// ---------------------------------------------------------------------------
/**
* Read taskHistory.json from the state directory.
* Returns an empty array if the file is missing or corrupt.
*/
export function readTaskHistory(dataDir?: string): HistoryItem[] {
return readJsonFile<HistoryItem[]>(taskHistoryPath(dataDir), [])
}
// ---------------------------------------------------------------------------
// Per-task data
// ---------------------------------------------------------------------------
/**
* Read the API conversation history for a specific task.
* Returns an empty array if the file is missing or corrupt.
*/
export function readApiConversationHistory(taskId: string, dataDir?: string): Anthropic.MessageParam[] {
return readJsonFile<Anthropic.MessageParam[]>(apiConversationHistoryPath(taskId, dataDir), [])
}
/**
* Read the UI messages for a specific task.
* Returns an empty array if the file is missing or corrupt.
*/
export function readUiMessages(taskId: string, dataDir?: string): ClineMessage[] {
return readJsonFile<ClineMessage[]>(uiMessagesPath(taskId, dataDir), [])
}
/**
* Read the context history for a specific task.
* Returns an empty array if the file is missing or corrupt.
*/
export function readContextHistory(taskId: string, dataDir?: string): unknown[] {
return readJsonFile<unknown[]>(contextHistoryPath(taskId, dataDir), [])
}
/**
* Read task metadata for a specific task.
* Returns an empty object if the file is missing or corrupt.
*/
export function readTaskMetadata(taskId: string, dataDir?: string): Record<string, unknown> {
return readJsonFile<Record<string, unknown>>(taskMetadataPath(taskId, dataDir), {})
}
// ---------------------------------------------------------------------------
// MCP settings
// ---------------------------------------------------------------------------
/** Shape of the MCP settings file */
export interface McpSettingsFile {
mcpServers: Record<
string,
{
/** Command to run (stdio transport) */
command?: string
/** Arguments for the command */
args?: string[]
/** Environment variables */
env?: Record<string, string>
/** URL for SSE/streamableHTTP transport */
url?: string
/** Whether the server is disabled */
disabled?: boolean
/** Auto-approve settings for tools */
autoApprove?: string[]
/** Timeout in milliseconds */
timeout?: number
/** Transport type */
transport?: "stdio" | "sse" | "streamableHttp"
}
>
}
/**
* Read the MCP settings file.
* Returns an empty mcpServers object if the file is missing or corrupt.
*/
export function readMcpSettings(dataDir?: string): McpSettingsFile {
return readJsonFile<McpSettingsFile>(mcpSettingsPath(dataDir), { mcpServers: {} })
}
// ---------------------------------------------------------------------------
// Task directory listing
// ---------------------------------------------------------------------------
/**
* List all task IDs that have directories on disk.
* Returns an empty array if the tasks directory doesn't exist.
*/
export function listTaskIds(dataDir?: string): string[] {
const tasksDir = path.join(resolveDataDir(dataDir), "tasks")
try {
if (!fs.existsSync(tasksDir)) {
return []
}
return fs
.readdirSync(tasksDir, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)
} catch (error) {
Logger.warn(`[LegacyStateReader] Failed to list tasks in ${tasksDir}:`, error)
return []
}
}
// ---------------------------------------------------------------------------
// Composite reader
// ---------------------------------------------------------------------------
/** All legacy state read from disk in a single call */
export interface LegacyState {
globalState: Partial<GlobalStateAndSettings>
secrets: Partial<Secrets>
taskHistory: HistoryItem[]
mcpSettings: McpSettingsFile
}
/**
* Read all legacy state from disk in a single call.
* This is the primary entry point for bootstrapping the SDK adapter
* from existing on-disk data.
*/
export function readAllLegacyState(dataDir?: string): LegacyState {
return {
globalState: readGlobalState(dataDir),
secrets: readSecrets(dataDir),
taskHistory: readTaskHistory(dataDir),
mcpSettings: readMcpSettings(dataDir),
}
}
File diff suppressed because it is too large Load Diff
+921
View File
@@ -0,0 +1,921 @@
// Replaces classic message streaming from src/core/task/index.ts (see origin/main)
//
// Translates SDK session events into ClineMessage[] for webview consumption.
// The webview expects ClineMessage objects with ask/say types; this module
// maps SDK CoreSessionEvent and AgentEvent types to that format.
//
// Key mappings:
// - SDK "chunk" event (agent stream) → ClineMessage say="text" with partial=true
// - SDK "agent_event" content_start (text) → ClineMessage say="text" with partial=true
// - SDK "agent_event" content_start (reasoning) → ClineMessage say="reasoning" with partial=true
// - SDK "agent_event" content_start (tool) → ClineMessage say="tool" with partial=true
// IMPORTANT: The webview's ChatRow.tsx parses message.text as JSON when
// say==="tool", expecting ClineSayTool format: {tool, path, content, ...}.
// We must convert SDK tool names (read_files, editor, run_commands, etc.)
// and their inputs to this format.
// - SDK "agent_event" content_end → ClineMessage with partial=false
// - SDK "agent_event" content_start (tool: attempt_completion) → ClineMessage say="completion_result"
// - SDK "agent_event" content_end (tool: attempt_completion) → ClineMessage ask="completion_result"
// - SDK "agent_event" done → ClineMessage ask="completion_result" (only if attempt_completion not seen)
// - SDK "agent_event" error → ClineMessage say="error"
// - SDK "agent_event" usage → ClineMessage say="api_req_started" with ClineApiReqInfo JSON
// - SDK "ended" event → finalizes the session
import type { CoreSessionEvent } from "@clinebot/core"
import type { AgentEvent } from "@clinebot/shared"
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
import type { ClineApiReqInfo, ClineMessage, ClineSay, ClineSayTool } from "@shared/ExtensionMessage"
import { Logger } from "@shared/services/Logger"
// ---------------------------------------------------------------------------
// Translation result
// ---------------------------------------------------------------------------
/**
* Result of translating a single SDK event into ClineMessages.
* May produce zero or more messages.
*/
export interface TranslationResult {
/** Messages produced by this event */
messages: ClineMessage[]
/** Whether the session has ended */
sessionEnded: boolean
/** Whether the agent turn is complete */
turnComplete: boolean
/** Usage info if available */
usage?: {
tokensIn: number
tokensOut: number
cacheWrites?: number
cacheReads?: number
totalCost?: number
}
}
// ---------------------------------------------------------------------------
// State tracking for partial messages
// ---------------------------------------------------------------------------
/**
* Tracks the state of streaming content to properly handle
* partial message updates.
*/
export class MessageTranslatorState {
/** Current streaming text message timestamp (used for dedup) */
private streamingTextTs: number | undefined
/** Current streaming reasoning message timestamp */
private streamingReasoningTs: number | undefined
/** Current streaming tool message timestamp */
private streamingToolTs: number | undefined
/** Stored tool input from content_start — used at content_end which doesn't carry input */
private streamingToolInput: unknown | undefined
/** Stored tool name from content_start — used at content_end for consistency */
private streamingToolName: string | undefined
/** Monotonic counter for message timestamps */
private tsCounter = Date.now()
/** Generate a unique timestamp for a new message */
nextTs(): number {
return ++this.tsCounter
}
/** Get and increment for streaming text */
getStreamingTextTs(): number {
if (!this.streamingTextTs) {
this.streamingTextTs = this.nextTs()
}
return this.streamingTextTs
}
/** Clear streaming text (content ended) */
clearStreamingText(): number {
const ts = this.streamingTextTs ?? this.nextTs()
this.streamingTextTs = undefined
return ts
}
/** Get and increment for streaming reasoning */
getStreamingReasoningTs(): number {
if (!this.streamingReasoningTs) {
this.streamingReasoningTs = this.nextTs()
}
return this.streamingReasoningTs
}
/** Clear streaming reasoning (content ended) */
clearStreamingReasoning(): number {
const ts = this.streamingReasoningTs ?? this.nextTs()
this.streamingReasoningTs = undefined
return ts
}
/** Get streaming tool ts */
getStreamingToolTs(): number {
if (!this.streamingToolTs) {
this.streamingToolTs = this.nextTs()
}
return this.streamingToolTs
}
/** Store tool input from content_start for use at content_end */
setStreamingToolContext(toolName: string, input: unknown): void {
this.streamingToolName = toolName
this.streamingToolInput = input
}
/** Get the stored tool input (from content_start) */
getStreamingToolInput(): unknown | undefined {
return this.streamingToolInput
}
/** Get the stored tool name (from content_start) */
getStreamingToolName(): string | undefined {
return this.streamingToolName
}
/** Clear streaming tool */
clearStreamingTool(): number {
const ts = this.streamingToolTs ?? this.nextTs()
this.streamingToolTs = undefined
this.streamingToolInput = undefined
this.streamingToolName = undefined
return ts
}
/** Whether attempt_completion tool was called in this turn */
private attemptCompletionSeen = false
/** Mark that attempt_completion was called */
setAttemptCompletionSeen(): void {
this.attemptCompletionSeen = true
}
/** Check if attempt_completion was called in this turn */
wasAttemptCompletionSeen(): boolean {
return this.attemptCompletionSeen
}
/** Reset all streaming state (new turn) */
reset(): void {
this.streamingTextTs = undefined
this.streamingReasoningTs = undefined
this.streamingToolTs = undefined
this.streamingToolInput = undefined
this.streamingToolName = undefined
this.attemptCompletionSeen = false
}
}
// ---------------------------------------------------------------------------
// SDK tool name → classic ClineSayTool mapping
// ---------------------------------------------------------------------------
/**
* Map an SDK tool name and its input to a ClineSayTool object that the
* webview's ChatRow.tsx can render.
*
* The webview does `JSON.parse(message.text) as ClineSayTool` when
* `say === "tool"`, so the text MUST be valid ClineSayTool JSON.
*
* SDK tool names → classic tool names:
* read_files/read_file → readFile
* list_files → listFilesTopLevel / listFilesRecursive
* list_code_definition_names → listCodeDefinitionNames
* editor/replace_in_file → editedExistingFile
* write_to_file → newFileCreated
* apply_patch → editedExistingFile
* delete_file → fileDeleted
* run_commands/execute_command → (uses say="command", NOT say="tool")
* search_codebase/search_files → searchFiles
* fetch_web_content/web_fetch → webFetch
* web_search → webSearch
* skills/use_skill → useSkill
* ask_question/ask_followup_question → (not a visual tool — handled as text)
* MCP tools → (passed through with tool name as-is)
*/
function sdkToolToClineSayTool(toolName: string, input?: unknown): ClineSayTool {
// Parse input if it's a string (some SDK tools pass stringified JSON)
const parsedInput = parseToolInput(input)
switch (toolName) {
case "read_files":
case "read_file": {
const filePath = extractFirstFilePath(parsedInput)
return {
tool: "readFile",
path: filePath,
}
}
case "list_files": {
const dirPath = getStringField(parsedInput, "path") ?? ""
const recursive = getBooleanField(parsedInput, "recursive") ?? false
return {
tool: recursive ? "listFilesRecursive" : "listFilesTopLevel",
path: dirPath,
}
}
case "list_code_definition_names": {
const dirPath = getStringField(parsedInput, "path") ?? ""
return {
tool: "listCodeDefinitionNames",
path: dirPath,
}
}
case "editor":
case "replace_in_file": {
const filePath = getStringField(parsedInput, "path") ?? ""
const newText =
getStringField(parsedInput, "new_text") ??
getStringField(parsedInput, "new_str") ??
getStringField(parsedInput, "content")
const patch = getStringField(parsedInput, "patch") ?? getStringField(parsedInput, "diff")
const oldText = getStringField(parsedInput, "old_text") ?? getStringField(parsedInput, "old_str")
const isEdit = toolName === "replace_in_file" || !!oldText
return {
tool: isEdit ? "editedExistingFile" : "newFileCreated",
path: filePath,
content: newText,
diff: patch,
}
}
case "write_to_file": {
const filePath = getStringField(parsedInput, "path") ?? ""
const content = getStringField(parsedInput, "content") ?? getStringField(parsedInput, "new_text")
return {
tool: "newFileCreated",
path: filePath,
content,
}
}
case "apply_patch": {
const filePath = getStringField(parsedInput, "path") ?? ""
const patch = getStringField(parsedInput, "patch")
return {
tool: "editedExistingFile",
path: filePath,
diff: patch,
}
}
case "delete_file": {
const filePath = getStringField(parsedInput, "path") ?? ""
return {
tool: "fileDeleted",
path: filePath,
}
}
case "search_codebase":
case "search_files": {
const queries = getArrayField(parsedInput, "queries")
const regex =
queries?.join(", ") ?? getStringField(parsedInput, "queries") ?? getStringField(parsedInput, "regex") ?? ""
const path = getStringField(parsedInput, "path")
const filePattern = getStringField(parsedInput, "file_pattern") ?? getStringField(parsedInput, "filePattern")
return {
tool: "searchFiles",
regex,
path,
filePattern,
}
}
case "fetch_web_content":
case "web_fetch": {
const url = getStringField(parsedInput, "url") ?? ""
return {
tool: "webFetch",
path: url,
}
}
case "web_search": {
const query = getStringField(parsedInput, "query") ?? getStringField(parsedInput, "q") ?? ""
return {
tool: "webSearch",
path: query,
}
}
case "skills":
case "use_skill": {
const skillName = getStringField(parsedInput, "skill_name") ?? getStringField(parsedInput, "name") ?? ""
return {
tool: "useSkill",
path: skillName,
}
}
default: {
// MCP tools and unknown tools — pass through with the raw tool name.
const filePath =
getStringField(parsedInput, "path") ??
getStringField(parsedInput, "url") ??
getStringField(parsedInput, "command") ??
""
return {
tool: toolName as ClineSayTool["tool"],
path: filePath,
}
}
}
}
/**
* Parse tool input into a record if it's a string or object.
*/
function parseToolInput(input: unknown): Record<string, unknown> | undefined {
if (!input) return undefined
if (typeof input === "object" && !Array.isArray(input)) {
return input as Record<string, unknown>
}
if (typeof input === "string") {
try {
const parsed = JSON.parse(input)
if (typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed
}
} catch {
// Not JSON — return undefined
}
}
return undefined
}
/** Extract file paths from a read_files/read_file input */
function extractFilePaths(input: Record<string, unknown> | undefined): string[] {
if (!input) return []
const files = input.files
if (Array.isArray(files) && files.length > 0) {
const paths = files
.map((f) => {
if (typeof f === "string") return f
if (typeof f === "object" && f !== null) {
return ((f as Record<string, unknown>).path as string) ?? ""
}
return ""
})
.filter(Boolean)
if (paths.length > 0) {
return paths
}
}
const singlePath =
(input.path as string) ?? (input.file_path as string) ?? (input.filePath as string) ?? (input.filename as string) ?? ""
return singlePath ? [singlePath] : []
}
/** Extract the first file path from a read_files input */
function extractFirstFilePath(input: Record<string, unknown> | undefined): string {
return extractFilePaths(input)[0] ?? ""
}
/** Get a string field from a parsed input object */
function getStringField(input: Record<string, unknown> | undefined, field: string): string | undefined {
if (!input) return undefined
const value = input[field]
if (typeof value === "string") return value
return undefined
}
/** Get an array field from a parsed input object */
function getArrayField(input: Record<string, unknown> | undefined, field: string): string[] | undefined {
if (!input) return undefined
const value = input[field]
if (Array.isArray(value)) return value.map(String)
return undefined
}
/** Get a boolean field from a parsed input object */
function getBooleanField(input: Record<string, unknown> | undefined, field: string): boolean | undefined {
if (!input) return undefined
const value = input[field]
if (typeof value === "boolean") return value
return undefined
}
// ---------------------------------------------------------------------------
// Agent event translation
// ---------------------------------------------------------------------------
/**
* Translate an SDK AgentEvent into ClineMessage(s).
*/
function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState): ClineMessage[] {
const messages: ClineMessage[] = []
switch (event.type) {
case "content_start": {
switch (event.contentType) {
case "text": {
// The SDK emits MULTIPLE content_start events for streaming text.
// Each has `text` (the delta) and `accumulated` (full text so far).
// We use `accumulated` so the webview can update the message in-place
// with the growing text, giving smooth streaming. Using `text` (delta)
// would cause a "flip book" effect where each update replaces the
// previous content with just the new chunk.
const ts = state.getStreamingTextTs()
messages.push({
ts,
type: "say",
say: "text",
text: event.accumulated ?? event.text ?? "",
partial: true,
})
break
}
case "reasoning": {
// Same pattern as text — use accumulated reasoning for smooth streaming
const ts = state.getStreamingReasoningTs()
const reasoning = event.reasoning ?? ""
messages.push({
ts,
type: "say",
say: "reasoning",
reasoning,
partial: true,
})
break
}
case "tool": {
const toolName = event.toolName ?? "unknown"
const input = event.input
// Store tool context so content_end can use it
// (content_end doesn't carry the input)
state.setStreamingToolContext(toolName, input)
// attempt_completion is handled specially — it triggers
// the green "Task Completed" rectangle in the webview.
// In the classic extension, this was the ONLY way to show
// the completion UI. We emit say:"completion_result" here
// (partial) and ask:"completion_result" at content_end.
if (toolName === "attempt_completion") {
state.setAttemptCompletionSeen()
const parsedInput = parseToolInput(input)
const resultText = getStringField(parsedInput, "result") ?? ""
messages.push({
ts: state.getStreamingToolTs(),
type: "say",
say: "completion_result",
text: resultText,
partial: true,
})
break
}
// command tools use say="command" (not say="tool")
// because the webview renders commands differently
if (toolName === "run_commands" || toolName === "execute_command") {
const parsedInput = parseToolInput(input)
const commands = getArrayField(parsedInput, "commands")
const commandText =
commands?.join(" && ") ??
getStringField(parsedInput, "commands") ??
getStringField(parsedInput, "command") ??
""
messages.push({
ts: state.getStreamingToolTs(),
type: "say",
say: "command",
text: commandText,
partial: true,
})
break
}
// All other tools → say="tool" with ClineSayTool JSON
const sayTool = sdkToolToClineSayTool(toolName, input)
messages.push({
ts: state.getStreamingToolTs(),
type: "say",
say: "tool",
text: JSON.stringify(sayTool),
partial: true,
})
break
}
}
break
}
case "content_update": {
// Content updates provide incremental progress for tool calls.
// For the webview, we don't need to push every update — the
// content_start message with partial=true is sufficient until
// content_end finalizes it. This avoids flooding the webview
// with intermediate states.
break
}
case "content_end": {
switch (event.contentType) {
case "text": {
const ts = state.clearStreamingText()
messages.push({
ts,
type: "say",
say: "text",
text: event.text ?? "",
partial: false,
})
break
}
case "reasoning": {
const ts = state.clearStreamingReasoning()
messages.push({
ts,
type: "say",
say: "reasoning",
reasoning: event.reasoning ?? "",
partial: false,
})
break
}
case "tool": {
const toolName = event.toolName ?? "unknown"
// attempt_completion → emit ask:"completion_result" to
// finalize the green "Task Completed" rectangle and enable
// follow-up input. The say:"completion_result" was emitted
// at content_start (partial); now we emit the ask version
// which the webview uses to enable the follow-up textarea.
if (toolName === "attempt_completion") {
const storedInput = state.getStreamingToolInput()
const ts = state.clearStreamingTool()
const parsedInput = parseToolInput(storedInput)
const resultText = getStringField(parsedInput, "result") ?? ""
// Finalize the say:"completion_result" (non-partial)
// This renders the green "Task Completed" rectangle.
messages.push({
ts,
type: "say",
say: "completion_result",
text: resultText,
partial: false,
})
// Emit ask:"completion_result" with EMPTY text to enable
// follow-up input without rendering a second green rectangle.
// The webview's ChatRow renders ask:"completion_result" with
// empty text as an InvisibleSpacer, but still sets clineAsk
// which enables the follow-up textarea.
messages.push({
ts: state.nextTs(),
type: "ask",
ask: "completion_result",
text: "",
partial: false,
})
break
}
// command tools finalize as say="command" with commandCompleted=true.
// We keep the same timestamp to replace the streaming partial command row
// in-place, so it doesn't disappear (command_output rows are filtered out
// by combineCommandSequences in the chat pipeline).
if (toolName === "run_commands" || toolName === "execute_command") {
const storedInput = state.getStreamingToolInput()
const parsedInput = parseToolInput(storedInput)
const commands = getArrayField(parsedInput, "commands")
const commandText =
commands?.join(" && ") ??
getStringField(parsedInput, "commands") ??
getStringField(parsedInput, "command") ??
""
const outputStr = event.error
? `Error: ${event.error}`
: typeof event.output === "string"
? event.output
: JSON.stringify(event.output ?? "")
const ts = state.clearStreamingTool()
messages.push({
ts,
type: "say",
say: "command",
text: outputStr ? `${commandText}\n${COMMAND_OUTPUT_STRING}\n${outputStr}` : commandText,
partial: false,
commandCompleted: true,
})
break
}
// All other tools → finalize the say="tool" message
// Use the stored input from content_start since content_end
// doesn't carry the input (S6-24 fix)
const storedInput = state.getStreamingToolInput()
const ts = state.clearStreamingTool()
// Special handling: read_files may read multiple files in one tool call.
// Emit one readFile UI message per file so the tool group summary and
// list reflect what was actually read.
if (toolName === "read_files" || toolName === "read_file") {
const parsedInput = parseToolInput(storedInput)
const filePaths = extractFilePaths(parsedInput)
if (filePaths.length > 1) {
filePaths.forEach((filePath, index) => {
messages.push({
ts: index === 0 ? ts : state.nextTs(),
type: "say",
say: "tool",
text: JSON.stringify({ tool: "readFile", path: filePath } satisfies ClineSayTool),
partial: false,
})
})
break
}
}
const sayTool = sdkToolToClineSayTool(toolName, storedInput)
// If there's an error, include it in the tool message
if (event.error) {
messages.push({
ts,
type: "say",
say: "tool",
text: JSON.stringify(sayTool),
partial: false,
})
// Also push an error message
messages.push({
ts: state.nextTs(),
type: "say",
say: "error",
text: event.error,
partial: false,
})
} else {
messages.push({
ts,
type: "say",
say: "tool",
text: JSON.stringify(sayTool),
partial: false,
})
}
break
}
}
break
}
case "iteration_start": {
// New iteration — reset streaming state for the new turn
state.reset()
// Emit an api_req_started message for the webview's API request
// spinner and cost display. The classic Task emits this before
// each API request.
messages.push({
ts: state.nextTs(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
request: undefined, // Will be filled in by usage event
} satisfies ClineApiReqInfo),
partial: false,
})
break
}
case "iteration_end": {
// Iteration ended — no specific message needed
break
}
case "notice": {
// Agent notices are informational
messages.push({
ts: state.nextTs(),
type: "say",
say: "info",
text: event.message ?? "",
partial: false,
})
break
}
case "usage": {
// Usage events carry token counts. In the classic system,
// these are embedded in the api_req_started message's
// ClineApiReqInfo. We emit a separate api_req_started update
// with the usage data so the webview can display costs.
const usageEvent = event as unknown as Record<string, unknown>
const apiReqInfo: ClineApiReqInfo = {
tokensIn: (usageEvent.inputTokens as number) ?? 0,
tokensOut: (usageEvent.outputTokens as number) ?? 0,
cacheWrites: (usageEvent.cacheWrites as number) ?? undefined,
cacheReads: (usageEvent.cacheReads as number) ?? undefined,
}
messages.push({
ts: state.nextTs(),
type: "say",
say: "api_req_started",
text: JSON.stringify(apiReqInfo),
partial: false,
})
break
}
case "done": {
// Agent turn is complete. In the classic extension, the green
// "Task Completed" rectangle was ONLY shown when the agent
// explicitly called the attempt_completion tool. The done event
// just signals the turn ended.
//
// If attempt_completion was already handled (via content_start/
// content_end for that tool), we already emitted both
// say:"completion_result" and ask:"completion_result" there.
// We do NOT emit another completion_result here to avoid
// duplicate green rectangles.
//
// If attempt_completion was NOT called (e.g., the agent just
// responded with text), we still need to emit
// ask:"completion_result" to enable the follow-up input in
// the webview. But we emit it with empty text so the webview
// renders an invisible spacer (no green rectangle) while still
// setting clineAsk for follow-up messages.
if (!state.wasAttemptCompletionSeen()) {
messages.push({
ts: state.nextTs(),
type: "ask",
ask: "completion_result",
text: "",
partial: false,
})
}
break
}
case "error": {
messages.push({
ts: state.nextTs(),
type: "say",
say: "error",
text: event.error.message ?? "Unknown error",
partial: false,
})
break
}
default: {
// Log unhandled event types for debugging
Logger.warn(`[MessageTranslator] Unhandled agent event type: ${(event as AgentEvent).type}`)
break
}
}
return messages
}
// ---------------------------------------------------------------------------
// Core session event translation
// ---------------------------------------------------------------------------
/**
* Translate an SDK CoreSessionEvent into a TranslationResult.
*
* This is the primary entry point for event translation. It handles
* both top-level session events (chunk, ended, status) and nested
* agent events.
*/
export function translateSessionEvent(event: CoreSessionEvent, state: MessageTranslatorState): TranslationResult {
const result: TranslationResult = {
messages: [],
sessionEnded: false,
turnComplete: false,
}
switch (event.type) {
case "chunk": {
// Raw chunk events from the session stream.
// IMPORTANT: We do NOT emit these as text messages. The SDK sends
// raw model output (which may contain JSON, tool call fragments, etc.)
// as chunk events. The structured agent_event system (content_start,
// content_update, content_end) is the proper way to get displayable
// content. Emitting raw chunks would show JSON like
// {"type":"iteration_start",...} in the webview.
//
// The chunk events are useful for logging but should not be
// displayed to the user.
break
}
case "agent_event": {
// Agent events contain structured content (text, reasoning, tools)
const agentMessages = translateAgentEvent(event.payload.event, state)
result.messages.push(...agentMessages)
// Check for done/error events
if (event.payload.event.type === "done") {
result.turnComplete = true
}
if (event.payload.event.type === "error") {
result.turnComplete = true
}
// Extract usage from usage events
if (event.payload.event.type === "usage") {
result.usage = {
tokensIn: event.payload.event.inputTokens ?? 0,
tokensOut: event.payload.event.outputTokens ?? 0,
}
}
break
}
case "ended": {
result.sessionEnded = true
result.turnComplete = true
state.reset()
break
}
case "hook": {
// Tool hook events — translate to hook_status messages
const payload = event.payload
const hookName = payload.hookEventName
const toolName = payload.toolName
if (hookName === "tool_call") {
result.messages.push({
ts: state.nextTs(),
type: "say",
say: "hook_status" as ClineSay,
text: toolName ? `Running ${toolName}...` : "Running tool...",
partial: false,
})
} else if (hookName === "tool_result") {
result.messages.push({
ts: state.nextTs(),
type: "say",
say: "hook_status" as ClineSay,
text: toolName ? `${toolName} completed` : "Tool completed",
partial: false,
})
}
break
}
case "status": {
// Status updates — informational
Logger.log(`[MessageTranslator] Session status: ${event.payload.status}`)
break
}
case "team_progress":
case "pending_prompts":
case "pending_prompt_submitted": {
// These are handled by the team/subagent system, not translated
// to ClineMessages at this layer
break
}
default: {
Logger.warn(`[MessageTranslator] Unhandled session event type: ${(event as CoreSessionEvent).type}`)
break
}
}
return result
}
// ---------------------------------------------------------------------------
// HistoryItem ↔ SessionRecord mapping
// ---------------------------------------------------------------------------
/**
* Map a HistoryItem (classic format) to a partial SessionRecord-like object.
* Used when loading tasks from legacy storage.
*/
export function historyItemToSessionFields(item: {
id: string
task: string
ts: number
tokensIn: number
tokensOut: number
totalCost: number
modelId?: string
}): {
sessionId: string
prompt: string
startedAt: string
usage: { tokensIn: number; tokensOut: number; totalCost: number }
modelId?: string
} {
return {
sessionId: item.id,
prompt: item.task,
startedAt: new Date(item.ts).toISOString(),
usage: {
tokensIn: item.tokensIn,
tokensOut: item.tokensOut,
totalCost: item.totalCost,
},
modelId: item.modelId,
}
}
+219
View File
@@ -0,0 +1,219 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { getProviderSettingsManager, migrateProviders } from "./provider-migration"
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-provider-migration-"))
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
function writeJson(filePath: string, data: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, JSON.stringify(data, null, 2))
}
// ---------------------------------------------------------------------------
// migrateProviders
// ---------------------------------------------------------------------------
describe("migrateProviders", () => {
it("returns empty result when no legacy state exists", () => {
const result = migrateProviders(tempDir)
expect(result.migrated).toBe(false)
expect(result.providerCount).toBe(0)
})
it("migrates Anthropic provider from legacy state", () => {
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "anthropic",
actModeApiModelId: "claude-sonnet-4-6",
})
writeJson(path.join(tempDir, "secrets.json"), {
apiKey: "sk-ant-legacy-key",
})
const result = migrateProviders(tempDir)
expect(result.migrated).toBe(true)
expect(result.providerCount).toBe(1)
expect(result.lastUsedProvider).toBe("anthropic")
// Verify the provider settings were written correctly
const manager = getProviderSettingsManager(tempDir)
const settings = manager.getProviderSettings("anthropic")
expect(settings?.provider).toBe("anthropic")
expect(settings?.apiKey).toBe("sk-ant-legacy-key")
expect(settings?.model).toBe("claude-sonnet-4-6")
})
it("migrates OpenAI provider with base URL and headers", () => {
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "openai",
actModeOpenAiModelId: "gpt-4o",
openAiBaseUrl: "https://api.openai.com/v1",
openAiHeaders: { "X-Custom": "test" },
requestTimeoutMs: 30000,
})
writeJson(path.join(tempDir, "secrets.json"), {
openAiApiKey: "sk-openai-legacy",
})
const result = migrateProviders(tempDir)
expect(result.migrated).toBe(true)
expect(result.lastUsedProvider).toBe("openai")
const manager = getProviderSettingsManager(tempDir)
const settings = manager.getProviderSettings("openai")
expect(settings?.provider).toBe("openai")
expect(settings?.apiKey).toBe("sk-openai-legacy")
expect(settings?.model).toBe("gpt-4o")
})
it("migrates OpenRouter provider", () => {
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "openrouter",
actModeOpenRouterModelId: "anthropic/claude-sonnet-4",
})
writeJson(path.join(tempDir, "secrets.json"), {
openRouterApiKey: "sk-or-legacy",
})
const result = migrateProviders(tempDir)
expect(result.migrated).toBe(true)
expect(result.lastUsedProvider).toBe("openrouter")
})
it("migrates Bedrock provider with AWS credentials", () => {
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "bedrock",
awsRegion: "us-east-1",
awsUseCrossRegionInference: true,
})
writeJson(path.join(tempDir, "secrets.json"), {
awsAccessKey: "AKIA-legacy",
awsSecretKey: "secret-legacy",
})
const result = migrateProviders(tempDir)
expect(result.migrated).toBe(true)
expect(result.lastUsedProvider).toBe("bedrock")
})
it("migrates Ollama provider (local, no API key)", () => {
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "ollama",
actModeOllamaModelId: "llama3",
ollamaBaseUrl: "http://localhost:11434",
})
writeJson(path.join(tempDir, "secrets.json"), {})
const result = migrateProviders(tempDir)
expect(result.migrated).toBe(true)
expect(result.lastUsedProvider).toBe("ollama")
})
it("does not overwrite existing provider entries", () => {
// Pre-create a provider entry using the SDK's own method
// (direct file writes may not match the SDK's schema exactly)
const preManager = getProviderSettingsManager(tempDir)
preManager.saveProviderSettings({
provider: "anthropic",
model: "claude-sonnet-4-6",
apiKey: "already-migrated-key",
})
// Also write legacy state with a different key
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "anthropic",
})
writeJson(path.join(tempDir, "secrets.json"), {
apiKey: "legacy-key-should-not-overwrite",
})
// Creating a new manager triggers auto-migration
const manager = getProviderSettingsManager(tempDir)
const settings = manager.getProviderSettings("anthropic")
// The existing entry should NOT be overwritten
expect(settings?.apiKey).toBe("already-migrated-key")
expect(manager.read().providers.anthropic?.tokenSource).toBe("manual")
})
it("is idempotent — calling twice produces the same result", () => {
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "anthropic",
})
writeJson(path.join(tempDir, "secrets.json"), {
apiKey: "sk-ant-test",
})
const result1 = migrateProviders(tempDir)
const result2 = migrateProviders(tempDir)
// Second call should not add more providers
expect(result2.providerCount).toBe(result1.providerCount)
expect(result2.lastUsedProvider).toBe(result1.lastUsedProvider)
})
it("migrates Cline provider with account auth", () => {
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "cline",
actModeClineModelId: "anthropic/claude-sonnet-4",
})
writeJson(path.join(tempDir, "secrets.json"), {
clineApiKey: "cline-legacy-token",
})
const result = migrateProviders(tempDir)
expect(result.migrated).toBe(true)
expect(result.lastUsedProvider).toBe("cline")
})
it("handles missing secrets.json gracefully", () => {
writeJson(path.join(tempDir, "globalState.json"), {
mode: "act",
actModeApiProvider: "ollama",
actModeOllamaModelId: "llama3",
})
// No secrets.json — Ollama doesn't need one
const result = migrateProviders(tempDir)
expect(result.migrated).toBe(true)
})
})
// ---------------------------------------------------------------------------
// getProviderSettingsManager
// ---------------------------------------------------------------------------
describe("getProviderSettingsManager", () => {
it("returns a ProviderSettingsManager instance", () => {
const manager = getProviderSettingsManager(tempDir)
expect(manager).toBeDefined()
expect(typeof manager.read).toBe("function")
expect(typeof manager.getProviderSettings).toBe("function")
})
it("reads empty state when no providers exist", () => {
const manager = getProviderSettingsManager(tempDir)
const state = manager.read()
expect(Object.keys(state.providers)).toHaveLength(0)
})
})
+112
View File
@@ -0,0 +1,112 @@
// Replaces classic provider credential management (see origin/main)
//
// Uses the SDK's ProviderSettingsManager and migrateLegacyProviderSettings
// to migrate existing on-disk credentials from globalState.json + secrets.json
// to the SDK's providers.json format.
//
// The SDK handles:
// - Reading globalState.json and secrets.json
// - Mapping 30+ provider fields to SDK format
// - Never overwriting existing entries
// - Tagging migrated entries with tokenSource: "migration"
// - Auto-migration on ProviderSettingsManager construction
import path from "node:path"
import { ProviderSettingsManager } from "@clinebot/core"
import { Logger } from "@shared/services/Logger"
import { resolveDataDir } from "./legacy-state-reader"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Result of the provider migration process */
export interface ProviderMigrationResult {
/** Whether any providers were migrated */
migrated: boolean
/** Total number of providers after migration */
providerCount: number
/** The last-used provider ID */
lastUsedProvider?: string
}
// ---------------------------------------------------------------------------
// Provider migration
// ---------------------------------------------------------------------------
/**
* Run provider migration using the SDK's ProviderSettingsManager.
*
* The SDK's constructor automatically calls migrateLegacyProviderSettings()
* when a dataDir is provided or can be inferred. This function wraps that
* process and returns a typed result.
*
* Migration is idempotent — calling it multiple times is safe because the SDK
* never overwrites existing provider entries.
*
* @param dataDir Override for the Cline data directory. Defaults to
* resolveDataDir() which checks CLINE_DATA_DIR, CLINE_DIR, then ~/.cline/data.
* @returns Migration result indicating what happened
*/
export function migrateProviders(dataDir?: string): ProviderMigrationResult {
const resolvedDataDir = dataDir ?? resolveDataDir()
try {
// ProviderSettingsManager auto-migrates on construction when dataDir
// is provided or can be inferred from the filePath.
// The migration reads globalState.json + secrets.json and writes
// providers.json, never overwriting existing entries.
// We must set filePath explicitly so the manager reads/writes within
// the correct dataDir, not the default ~/.cline/data.
const filePath = path.join(resolvedDataDir, "settings", "providers.json")
const manager = new ProviderSettingsManager({ filePath, dataDir: resolvedDataDir })
const state = manager.read()
const lastUsed = manager.getLastUsedProviderSettings()
const result: ProviderMigrationResult = {
migrated: Object.values(state.providers).some((p) => p.tokenSource === "migration"),
providerCount: Object.keys(state.providers).length,
lastUsedProvider: state.lastUsedProvider ?? lastUsed?.provider,
}
Logger.log(
`[ProviderMigration] Migration complete: ${result.providerCount} providers, lastUsed=${result.lastUsedProvider ?? "none"}, migrated=${result.migrated}`,
)
return result
} catch (error) {
Logger.error("[ProviderMigration] Failed to migrate providers:", error)
return {
migrated: false,
providerCount: 0,
}
}
}
// ---------------------------------------------------------------------------
// Provider settings access (cached singleton)
// ---------------------------------------------------------------------------
let _cachedManager: ProviderSettingsManager | null = null
let _cachedDataDir: string | null = null
/**
* Get the ProviderSettingsManager singleton for the given data directory.
*
* Construction triggers auto-migration if needed, so this is the primary
* way to access provider settings throughout the SDK adapter layer.
*
* The instance is cached so all callers share the same in-memory state.
* Pass a different dataDir to force a new instance (e.g. in tests).
*/
export function getProviderSettingsManager(dataDir?: string): ProviderSettingsManager {
const resolvedDataDir = dataDir ?? resolveDataDir()
if (_cachedManager && _cachedDataDir === resolvedDataDir) {
return _cachedManager
}
const filePath = path.join(resolvedDataDir, "settings", "providers.json")
_cachedManager = new ProviderSettingsManager({ filePath, dataDir: resolvedDataDir })
_cachedDataDir = resolvedDataDir
return _cachedManager
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from "vitest"
import { createTaskProxy, MessageStateHandler } from "./task-proxy"
describe("MessageStateHandler", () => {
it("should start with empty messages", () => {
const handler = new MessageStateHandler()
expect(handler.getClineMessages()).toEqual([])
})
it("should add and retrieve messages", () => {
const handler = new MessageStateHandler()
const messages = [
{ ts: 1, type: "say" as const, say: "text" as const, text: "hello", partial: false },
{ ts: 2, type: "say" as const, say: "tool" as const, text: "tool call", partial: false },
]
handler.addMessages(messages)
expect(handler.getClineMessages()).toHaveLength(2)
expect(handler.getClineMessages()[0].text).toBe("hello")
expect(handler.getClineMessages()[1].text).toBe("tool call")
})
it("should return a copy of messages", () => {
const handler = new MessageStateHandler()
handler.addMessages([{ ts: 1, type: "say", say: "text", text: "hello", partial: false }])
const copy = handler.getClineMessages()
copy.push({ ts: 2, type: "say", say: "text", text: "extra", partial: false })
expect(handler.getClineMessages()).toHaveLength(1)
})
it("should clear messages", () => {
const handler = new MessageStateHandler()
handler.addMessages([{ ts: 1, type: "say", say: "text", text: "hello", partial: false }])
handler.clear()
expect(handler.getClineMessages()).toEqual([])
})
it("should accumulate messages across multiple addMessages calls", () => {
const handler = new MessageStateHandler()
handler.addMessages([{ ts: 1, type: "say", say: "text", text: "first", partial: false }])
handler.addMessages([{ ts: 2, type: "say", say: "text", text: "second", partial: false }])
expect(handler.getClineMessages()).toHaveLength(2)
})
})
describe("createTaskProxy", () => {
it("should expose sessionId as ulid and taskId", () => {
const onAskResponse = vi.fn()
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
expect(proxy.ulid).toBe("session-123")
expect(proxy.taskId).toBe("session-123")
})
it("should delegate messageResponse to onAskResponse", async () => {
const onAskResponse = vi.fn().mockResolvedValue(undefined)
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
await proxy.handleWebviewAskResponse("messageResponse", "hello", ["img1"], ["file1"])
expect(onAskResponse).toHaveBeenCalledWith("hello", ["img1"], ["file1"])
})
it("should delegate yesButtonClicked to onAskResponse", async () => {
const onAskResponse = vi.fn().mockResolvedValue(undefined)
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
await proxy.handleWebviewAskResponse("yesButtonClicked", "", [], [])
expect(onAskResponse).toHaveBeenCalledWith("", [], [])
})
it("should delegate noButtonClicked to onAskResponse", async () => {
const onAskResponse = vi.fn().mockResolvedValue(undefined)
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
await proxy.handleWebviewAskResponse("noButtonClicked", "", [], [])
expect(onAskResponse).toHaveBeenCalledWith("", [], [])
})
it("should delegate unknown askResponse types to onAskResponse", async () => {
const onAskResponse = vi.fn().mockResolvedValue(undefined)
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
// biome-ignore lint/suspicious/noExplicitAny: testing unknown ask response type
await proxy.handleWebviewAskResponse("command" as any, "ls -la", [], [])
expect(onAskResponse).toHaveBeenCalledWith("ls -la", [], [])
})
it("should store askResponse in taskState", async () => {
const onAskResponse = vi.fn().mockResolvedValue(undefined)
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
await proxy.handleWebviewAskResponse("messageResponse", "hello")
expect(proxy.taskState.askResponse).toBe("messageResponse")
})
it("should delegate abortTask to onCancelTask", async () => {
const onAskResponse = vi.fn()
const onCancelTask = vi.fn().mockResolvedValue(undefined)
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
await proxy.abortTask()
expect(onCancelTask).toHaveBeenCalled()
})
it("should provide a stub API handler", () => {
const onAskResponse = vi.fn()
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
expect(proxy.api.getModel().id).toBe("unknown")
})
it("should return undefined for removed features and stubs for terminal manager", () => {
const onAskResponse = vi.fn()
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
expect(proxy.browserSession).toBeUndefined()
expect(proxy.checkpointManager).toBeUndefined()
// terminalManager is a stub object with no-op settings methods
expect(proxy.terminalManager).toBeDefined()
expect(typeof proxy.terminalManager.setDefaultTerminalProfile).toBe("function")
expect(typeof proxy.terminalManager.setShellIntegrationTimeout).toBe("function")
})
it("should provide a messageStateHandler", () => {
const onAskResponse = vi.fn()
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
expect(proxy.messageStateHandler).toBeInstanceOf(MessageStateHandler)
expect(proxy.messageStateHandler.getClineMessages()).toEqual([])
})
it("should accumulate messages in messageStateHandler", () => {
const onAskResponse = vi.fn()
const onCancelTask = vi.fn()
const proxy = createTaskProxy("session-123", onAskResponse, onCancelTask)
proxy.messageStateHandler.addMessages([{ ts: 1, type: "say", say: "text", text: "hello", partial: false }])
expect(proxy.messageStateHandler.getClineMessages()).toHaveLength(1)
})
})
+272
View File
@@ -0,0 +1,272 @@
// Replaces classic src/core/task/index.ts task object (see origin/main)
//
// When handlers call controller.task.handleWebviewAskResponse(), controller.task.ulid,
// controller.task.abortTask(), etc., this proxy delegates to the SdkController's
// session methods. This allows existing gRPC handlers to work without modification.
//
// Not all classic Task methods are implemented here — only those called by
// the gRPC handler modules in src/core/controller/. Missing methods log a warning
// and return safe defaults.
import { EventEmitter } from "node:events"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Logger } from "@shared/services/Logger"
import type { ClineAskResponse } from "@shared/WebviewMessage"
/**
* Interface for the task proxy — mirrors the subset of classic Task
* properties and methods that gRPC handlers actually call.
*/
export interface TaskProxy {
/** Session ID (maps to classic task's ulid/taskId) */
ulid: string
taskId: string
/** Delegate ask response to the controller's session */
handleWebviewAskResponse: (askResponse: ClineAskResponse, text?: string, images?: string[], files?: string[]) => Promise<void>
/** Abort the running task */
abortTask: () => Promise<void>
/** API handler — settable for model switching via updateSettings */
api: TaskProxyApi
/** Browser session — stubbed (browser automation removed in this migration) */
// biome-ignore lint/suspicious/noExplicitAny: typed as any for handler compatibility; browser automation removed
browserSession: any
/** Checkpoint manager — stubbed (shadow git removed in this migration) */
// biome-ignore lint/suspicious/noExplicitAny: typed as any for handler compatibility; checkpoints removed
checkpointManager: any
/** Terminal manager — stub that safely no-ops for settings compatibility */
terminalManager: TaskProxyTerminalManager
/** Task state for tracking */
taskState: TaskProxyState
/** Message state handler — accumulates messages for state building */
messageStateHandler: MessageStateHandler
}
/**
* Event map for MessageStateHandler.
* Mirrors the classic MessageStateHandlerEvents from src/core/task/message-state.ts.
* Uses tuple syntax for EventEmitter compatibility.
*/
export interface MessageStateHandlerEvents {
clineMessagesChanged: [change: ClineMessageChange]
}
/**
* Change event for message updates.
* Mirrors ClineMessageChange from src/core/task/message-state.ts.
*/
export interface ClineMessageChange {
type: "add" | "update" | "set" | "delete"
/** The full array after the change */
messages: ClineMessage[]
/** The affected index (for add/update/delete) */
index?: number
/** The new/updated message (for add/update) */
message?: ClineMessage
}
/**
* Message state handler that accumulates ClineMessages and emits change events.
* Extends EventEmitter for compatibility with the classic MessageStateHandler
* used by the CLI's ClineAgent (on/off event subscription pattern).
*
* The classic Task had a full MessageStateHandler; this provides the
* getClineMessages() and event emitter interface that consumers expect.
*/
export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents> {
private messages: ClineMessage[] = []
/** Add or update messages from a session event.
* If a message with the same `ts` already exists, update it in-place
* (this handles partial→final streaming updates). Otherwise append.
* This prevents duplicate messages when both partial message stream
* and state updates carry the same content.
*/
addMessages(messages: ClineMessage[]): void {
for (const message of messages) {
const existingIndex = this.messages.findIndex((m) => m.ts === message.ts)
if (existingIndex !== -1) {
// Update existing message in-place (e.g., partial=true → partial=false)
this.messages[existingIndex] = message
this.emit("clineMessagesChanged", {
type: "update",
messages: this.messages,
index: existingIndex,
message,
})
} else {
this.messages.push(message)
this.emit("clineMessagesChanged", { type: "add", messages: this.messages, message })
}
}
}
/** Get all accumulated messages (returns a copy) */
getClineMessages(): ClineMessage[] {
return [...this.messages]
}
/** Clear all messages (e.g., on task clear) */
clear(): void {
this.messages = []
}
}
/**
* Minimal API handler interface for model info access.
* The classic Task had a full API handler; we expose just what handlers need.
* The `api` property is settable — updateSettings() replaces it when the
* user switches models/providers.
*/
export interface TaskProxyApi {
getModel: () => { id: string }
}
/**
* Terminal manager stub for settings compatibility.
* The updateSettings handler calls setDefaultTerminalProfile() which
* returns { closedCount, busyTerminals }. This stub safely no-ops.
*/
export interface TaskProxyTerminalManager {
setDefaultTerminalProfile: (profileId: string) => { closedCount: number; busyTerminals: never[] }
setShellIntegrationTimeout: (timeout: number) => void
setTerminalReuseEnabled: (enabled: boolean) => void
setTerminalOutputLineLimit: (limit: number) => void
}
/**
* Task state tracking — mirrors the subset of classic TaskState
* that handlers reference.
*/
export interface TaskProxyState {
askResponse?: ClineAskResponse
autoRetryAttempts?: number
/** Checkpoint manager initialized flag (stub — checkpoints removed) */
isInitialized?: boolean
/** Checkpoint manager error message (stub — checkpoints removed) */
checkpointManagerErrorMessage?: string
/** Focus chain checklist (stub — focus chain removed) */
currentFocusChainChecklist?: null
/** Abort flag for task cancellation (classic TaskState used boolean) */
abort?: boolean
}
/**
* Callback type for delegating ask responses to the controller.
*/
export type AskResponseCallback = (text?: string, images?: string[], files?: string[]) => Promise<void>
/**
* Callback type for delegating task cancellation to the controller.
*/
export type CancelTaskCallback = () => Promise<void>
/**
* Create a task proxy that delegates to the SdkController.
*
* @param sessionId The SDK session ID
* @param onAskResponse Callback to send a response to the active session
* @param onCancelTask Callback to cancel the active session
* @returns A TaskProxy object
*/
export function createTaskProxy(
sessionId: string,
onAskResponse: AskResponseCallback,
onCancelTask: CancelTaskCallback,
): TaskProxy {
const state: TaskProxyState = {}
const messageStateHandler = new MessageStateHandler()
// Mutable session ID — updated when the session is restarted (e.g., MCP tool reload)
let currentSessionId = sessionId
// Mutable API handler — updateSettings() replaces it when switching models
let currentApi: TaskProxyApi = {
getModel: () => ({ id: "unknown" }),
}
// Terminal manager stub — safely no-ops for settings compatibility
const terminalManagerStub: TaskProxyTerminalManager = {
setDefaultTerminalProfile: () => ({ closedCount: 0, busyTerminals: [] }),
setShellIntegrationTimeout: () => {},
setTerminalReuseEnabled: () => {},
setTerminalOutputLineLimit: () => {},
}
const proxy: TaskProxy = {
get ulid(): string {
return currentSessionId
},
get taskId(): string {
return currentSessionId
},
set taskId(newId: string) {
currentSessionId = newId
},
async handleWebviewAskResponse(
askResponse: ClineAskResponse,
text?: string,
images?: string[],
files?: string[],
): Promise<void> {
// Store the response type in task state (some handlers check this)
state.askResponse = askResponse
switch (askResponse) {
case "yesButtonClicked":
case "noButtonClicked":
// For approval responses, we just send an empty continuation
// The SDK handles approval differently than the classic Task
await onAskResponse(text, images, files)
break
case "messageResponse":
// User sent a follow-up message
await onAskResponse(text, images, files)
break
default:
Logger.warn(`[TaskProxy] Unhandled askResponse type: ${askResponse}`)
await onAskResponse(text, images, files)
break
}
},
async abortTask(): Promise<void> {
await onCancelTask()
},
get api(): TaskProxyApi {
return currentApi
},
set api(handler: TaskProxyApi) {
// updateSettings() replaces the API handler when switching models/providers
currentApi = handler
},
get browserSession() {
// Browser automation removed — see ARCHITECTURE.md
return undefined
},
get checkpointManager() {
// Shadow git checkpoints removed — see ARCHITECTURE.md
return undefined
},
get terminalManager(): TaskProxyTerminalManager {
return terminalManagerStub
},
get taskState(): TaskProxyState {
return state
},
get messageStateHandler(): MessageStateHandler {
return messageStateHandler
},
}
return proxy
}
+271
View File
@@ -0,0 +1,271 @@
// Custom RuntimeBuilder that bridges the classic McpHub to the SDK's tool system.
//
// The SDK's DefaultRuntimeBuilder.loadConfiguredMcpTools() creates an
// InMemoryMcpManager with createDefaultMcpServerClientFactory() which only
// supports stdio transport. This custom builder instead reads MCP tools from
// the classic McpHub, which already supports all three transports (stdio,
// SSE, streamableHttp) and provides file watching, dynamic connect/disconnect,
// and server status UI.
//
// Architecture:
// VscodeRuntimeBuilder
// ├── Builtin tools: delegates to DefaultRuntimeBuilder
// └── MCP tools: reads from classic McpHub via McpHubToolProvider
//
// Future: When the SDK's InMemoryMcpManager supports all transports, this
// can be replaced with the default builder. See PROBLEMS.md S6-10.
import { DefaultRuntimeBuilder } from "@clinebot/core"
import { createTool, type Tool, type ToolContext } from "@clinebot/shared"
import type { McpHub } from "@/services/mcp/McpHub"
import { Logger } from "@/shared/services/Logger"
// ---------------------------------------------------------------------------
// McpHub → SDK McpToolProvider adapter
// ---------------------------------------------------------------------------
/**
* Adapter that makes the classic McpHub look like an SDK McpToolProvider.
*
* The SDK's createMcpTools() expects a provider with listTools() and callTool()
* methods. This adapter delegates those calls to the classic McpHub, which
* already has connected MCP servers with all transport types.
*/
class McpHubToolProvider {
constructor(private mcpHub: McpHub) {}
/**
* List tools for a given MCP server by reading from the classic McpHub.
*/
async listTools(serverName: string): Promise<readonly McpToolDescriptor[]> {
const servers = this.mcpHub.getServers()
const server = servers.find((s) => s.name === serverName)
if (!server) {
Logger.warn(`[McpHubToolProvider] Server not found: ${serverName}`)
return []
}
return (server.tools ?? []).map((tool) => ({
name: tool.name,
description: tool.description ?? undefined,
inputSchema: (tool.inputSchema as Record<string, unknown>) ?? {
type: "object",
properties: {},
},
}))
}
/**
* Call a tool on an MCP server via the classic McpHub.
*/
async callTool(request: {
serverName: string
toolName: string
arguments?: Record<string, unknown>
context?: ToolContext
}): Promise<unknown> {
// McpHub.callTool requires a ulid (unique log identifier) for tracking.
// Generate a simple unique ID since we don't need cross-reference tracking.
const ulid = `sdk-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
const result = await this.mcpHub.callTool(request.serverName, request.toolName, request.arguments ?? {}, ulid)
return result
}
}
/** Minimal tool descriptor matching the SDK's McpToolDescriptor */
interface McpToolDescriptor {
name: string
description?: string
inputSchema: Record<string, unknown>
}
// ---------------------------------------------------------------------------
// MCP tool name transform (matches SDK's defaultMcpToolNameTransform)
// ---------------------------------------------------------------------------
/**
* Transform MCP server+tool names into a single agent tool name.
* Format: `serverName__toolName` (double underscore separator).
* This matches the SDK's defaultMcpToolNameTransform.
*/
function mcpToolNameTransform(input: { serverName: string; toolName: string }): string {
return `${input.serverName}__${input.toolName}`
}
// ---------------------------------------------------------------------------
// attempt_completion tool
// ---------------------------------------------------------------------------
/**
* Create the attempt_completion tool for the SDK agent.
*
* In the classic extension, attempt_completion was a built-in tool that
* triggered the green "Task Completed" rectangle in the webview. The SDK
* doesn't have this tool built-in, so we register it as a custom tool.
*
* The tool's execute function is a no-op — it just returns a success
* message. The actual UI behavior (green rectangle, follow-up input) is
* handled by the message translator when it sees content_start/content_end
* events with toolName === "attempt_completion".
*/
function createAttemptCompletionTool(): Tool {
return createTool({
name: "attempt_completion",
description:
"Once you've completed the user's task, use this tool to present the result to the user. " +
"The user may provide feedback if they are not satisfied, which you can use to make improvements and try again.",
inputSchema: {
type: "object",
properties: {
result: {
type: "string",
description: "A clear, brief summary of the final result of the task.",
},
command: {
type: "string",
description:
"An optional terminal command to showcase the result (e.g. open a dev server). " +
"Do not use commands like echo or cat that merely print text.",
},
},
required: ["result"],
},
execute: async (input: unknown) => {
// No-op: the message translator handles the UI display.
// We return the result text so it appears in the agent event stream.
const parsedInput = input && typeof input === "object" ? (input as Record<string, unknown>) : {}
const result = typeof parsedInput.result === "string" ? parsedInput.result : "Task completed."
return result
},
})
}
// ---------------------------------------------------------------------------
// VscodeRuntimeBuilder
// ---------------------------------------------------------------------------
/**
* Custom RuntimeBuilder for the VSCode extension that uses the classic
* McpHub for MCP tool management instead of the SDK's InMemoryMcpManager.
*
* This builder:
* 1. Delegates to DefaultRuntimeBuilder for builtin tools (editor, bash, etc.)
* 2. Reads MCP tools from the classic McpHub's already-connected servers
* 3. Creates SDK Tool objects that call through to McpHub.callTool()
*
* Benefits over the SDK's default MCP loading:
* - Supports all transport types (stdio, SSE, streamableHttp)
* - Uses already-connected servers (no duplicate connections)
* - File watching and dynamic connect/disconnect work via McpHub
* - Server status is visible in the webview UI
*
* Usage:
* ```ts
* const runtimeBuilder = new VscodeRuntimeBuilder(mcpHub)
* // Pass to DefaultSessionManager constructor:
* // new DefaultSessionManager({ runtimeBuilder, ... })
* ```
*/
export class VscodeRuntimeBuilder {
private defaultBuilder: DefaultRuntimeBuilder
private mcpHub: McpHub
constructor(mcpHub: McpHub) {
this.mcpHub = mcpHub
this.defaultBuilder = new DefaultRuntimeBuilder()
}
/**
* Build the runtime tools for a session.
*
* Delegates to DefaultRuntimeBuilder for builtin tools, then adds
* MCP tools from the classic McpHub's connected servers, plus the
* attempt_completion tool for task completion UI.
*/
async build(input: Parameters<DefaultRuntimeBuilder["build"]>[0]) {
// 1. Build builtin tools using the default builder
const defaultRuntime = await this.defaultBuilder.build(input)
// 2. Remove any MCP tools that the default builder may have loaded
// (from the filtered settings file). We'll replace them with
// tools from the classic McpHub.
const builtinTools = defaultRuntime.tools.filter(
(tool) => !tool.name.includes("__"), // MCP tools use serverName__toolName format
)
// 3. Load MCP tools from the classic McpHub
const mcpTools = await this.loadMcpToolsFromHub()
// 4. Create the attempt_completion tool
const completionTool = createAttemptCompletionTool()
// 5. Combine
const allTools = [...builtinTools, completionTool, ...mcpTools]
Logger.log(
`[VscodeRuntimeBuilder] Built runtime: ${builtinTools.length} builtin + 1 completion + ${mcpTools.length} MCP tools`,
)
return {
...defaultRuntime,
tools: allTools,
shutdown: async (reason: string) => {
await defaultRuntime.shutdown(reason)
},
}
}
/**
* Load MCP tools from the classic McpHub's connected servers.
*
* For each connected server, we list its tools and create SDK Tool
* objects that delegate calls to McpHub.callTool(). This gives the
* SDK agent access to all MCP servers regardless of transport type.
*/
private async loadMcpToolsFromHub(): Promise<Tool[]> {
const tools: Tool[] = []
const provider = new McpHubToolProvider(this.mcpHub)
const servers = this.mcpHub.getServers()
for (const server of servers) {
if (server.disabled) {
continue
}
try {
const descriptors = await provider.listTools(server.name)
for (const descriptor of descriptors) {
const agentToolName = mcpToolNameTransform({
serverName: server.name,
toolName: descriptor.name,
})
const tool = createTool({
name: agentToolName,
description:
descriptor.description ?? `Execute MCP tool "${descriptor.name}" from server "${server.name}".`,
inputSchema: descriptor.inputSchema,
execute: async (input: unknown, context: ToolContext) => {
const args =
input && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: undefined
return provider.callTool({
serverName: server.name,
toolName: descriptor.name,
arguments: args,
context,
})
},
})
tools.push(tool)
}
} catch (error) {
Logger.warn(`[VscodeRuntimeBuilder] Failed to load tools from MCP server "${server.name}": ${error}`)
}
}
return tools
}
}
+232
View File
@@ -0,0 +1,232 @@
// VscodeSessionHost — wraps DefaultSessionManager with VSCode-specific customizations
//
// Replaces ClineCore.create() with direct DefaultSessionManager construction,
// allowing us to pass custom runtimeBuilder, oauthTokenManager, and other options
// that ClineCore.create() doesn't expose.
//
// Key customizations (see PROBLEMS.md S6-9 for full rationale):
// 1. VscodeRuntimeBuilder — bridges classic McpHub to SDK tool system
// 2. OAuth tokens read from providers.json via shared ProviderSettingsManager
// (the SDK's default RuntimeOAuthTokenManager handles refresh/persistence)
// 3. source: "vscode" — tags sessions for telemetry instead of default "cli"
// 4. requestToolApproval — VSCode approval UI integration (future)
import {
type CoreSessionEvent,
DefaultSessionManager,
type HookEventPayload,
resolveSessionBackend,
type SendSessionInput,
type SessionAccumulatedUsage,
type SessionManager,
type SessionRecord,
type StartSessionInput,
type StartSessionResult,
} from "@clinebot/core"
import { type ToolApprovalRequest, type ToolApprovalResult } from "@clinebot/shared"
import { writeFileSync } from "fs"
import { join } from "path"
import type { McpHub } from "@/services/mcp/McpHub"
import { Logger } from "@/shared/services/Logger"
import { resolveDataDir } from "./legacy-state-reader"
import { VscodeRuntimeBuilder } from "./vscode-runtime-builder"
// ---------------------------------------------------------------------------
// VscodeSessionHost
// ---------------------------------------------------------------------------
export interface VscodeSessionHostOptions {
/** The classic McpHub for MCP tool bridging */
mcpHub: McpHub
/** Optional tool approval callback for VSCode approval UI integration */
requestToolApproval?: (request: {
agentId: string
conversationId: string
iteration: number
toolCallId: string
toolName: string
input: unknown
policy: { enabled: boolean; autoApprove: boolean }
}) => Promise<{ approved: boolean; reason?: string }>
}
/**
* VSCode-specific SessionManager that wraps DefaultSessionManager with
* custom runtime builder, OAuth token manager, and session source tagging.
*
* This replaces ClineCore.create() with direct DefaultSessionManager construction,
* giving us control over:
* - runtimeBuilder: Uses VscodeRuntimeBuilder to bridge classic McpHub
* - oauthTokenManager: SDK's RuntimeOAuthTokenManager reads from providers.json
* - source: Tags sessions as "vscode" for telemetry
* - requestToolApproval: VSCode approval UI integration (future)
*
* Usage:
* ```ts
* const host = await VscodeSessionHost.create({ mcpHub })
* const result = await host.start({ config, prompt, interactive: true })
* const unsub = host.subscribe((event) => { ... })
* ```
*/
export class VscodeSessionHost implements SessionManager {
private inner!: DefaultSessionManager
private constructor() {}
/**
* Create a VscodeSessionHost with custom options.
*
* This resolves the session backend (local SQLite/file storage),
* creates the VscodeRuntimeBuilder, and uses the SDK's default
* RuntimeOAuthTokenManager backed by the shared ProviderSettingsManager
* (providers.json) for OAuth token resolution.
*/
static async create(options: VscodeSessionHostOptions): Promise<VscodeSessionHost> {
const host = new VscodeSessionHost()
// Ensure MCP settings are filtered for the SDK's default builder.
// The VscodeRuntimeBuilder delegates to DefaultRuntimeBuilder for builtin
// tools, which calls loadConfiguredMcpTools(). We point it to an empty
// settings file so it loads no MCP tools — the VscodeRuntimeBuilder
// replaces them with tools from the classic McpHub.
await ensureEmptyMcpSettings()
// Resolve session backend (local mode — SQLite with file fallback)
const sessionService = await resolveSessionBackend({ backendMode: "local" })
// Create custom runtime builder that bridges classic McpHub
const runtimeBuilder = new VscodeRuntimeBuilder(options.mcpHub)
// Don't pass oauthTokenManager — the SDK's DefaultSessionManager creates
// its own RuntimeOAuthTokenManager with a default ProviderSettingsManager
// that reads from ~/.cline/data/settings/providers.json. AuthService
// writes credentials to the same file, so they stay in sync.
host.inner = new DefaultSessionManager({
sessionService,
runtimeBuilder,
requestToolApproval: options.requestToolApproval as
| ((request: ToolApprovalRequest) => Promise<ToolApprovalResult>)
| undefined,
distinctId: "cline-vscode",
})
Logger.log("[VscodeSessionHost] Initialized with VscodeRuntimeBuilder + SDK default OAuth")
return host
}
// ---- SessionManager implementation ----
async start(input: StartSessionInput): Promise<StartSessionResult> {
// Inject source: "vscode" for telemetry (SDK defaults to "cli")
const modifiedInput: StartSessionInput = {
...input,
source: input.source ?? "vscode",
}
return this.inner.start(modifiedInput)
}
async send(input: SendSessionInput) {
Logger.log(`[VscodeSessionHost] send() called: sessionId=${input.sessionId}, prompt=${input.prompt?.substring(0, 50)}`)
try {
const result = await this.inner.send(input)
Logger.log(
`[VscodeSessionHost] send() completed: text=${result?.text?.substring(0, 100)}, inputTokens=${result?.usage?.inputTokens}`,
)
return result
} catch (error) {
Logger.error(`[VscodeSessionHost] send() error:`, error)
throw error
}
}
async getAccumulatedUsage(sessionId: string): Promise<SessionAccumulatedUsage | undefined> {
return this.inner.getAccumulatedUsage(sessionId)
}
async abort(sessionId: string, reason?: unknown): Promise<void> {
return this.inner.abort(sessionId, reason)
}
async stop(sessionId: string): Promise<void> {
return this.inner.stop(sessionId)
}
async dispose(reason?: string): Promise<void> {
return this.inner.dispose(reason)
}
async get(sessionId: string): Promise<SessionRecord | undefined> {
return this.inner.get(sessionId)
}
async list(limit?: number): Promise<SessionRecord[]> {
return this.inner.list(limit)
}
async delete(sessionId: string): Promise<boolean> {
return this.inner.delete(sessionId)
}
async readMessages(sessionId: string) {
return this.inner.readMessages(sessionId)
}
async readTranscript(sessionId: string, maxChars?: number): Promise<string> {
return this.inner.readTranscript(sessionId, maxChars)
}
async update(
sessionId: string,
updates: {
prompt?: string | null
metadata?: Record<string, unknown> | null
title?: string | null
},
): Promise<{ updated: boolean }> {
return this.inner.update(sessionId, updates)
}
async handleHookEvent(payload: HookEventPayload): Promise<void> {
return this.inner.handleHookEvent(payload)
}
subscribe(listener: (event: CoreSessionEvent) => void): () => void {
return this.inner.subscribe(listener)
}
}
// ---------------------------------------------------------------------------
// MCP settings helpers
// ---------------------------------------------------------------------------
/**
* Write an empty MCP settings file and point the SDK to it.
*
* The VscodeRuntimeBuilder delegates to DefaultRuntimeBuilder for builtin
* tools, which calls loadConfiguredMcpTools(). This function reads from
* CLINE_MCP_SETTINGS_PATH. By pointing it to an empty settings file, we
* ensure the default builder loads no MCP tools — the VscodeRuntimeBuilder
* replaces them with tools from the classic McpHub which supports all
* transport types (stdio, SSE, streamableHttp).
*/
async function ensureEmptyMcpSettings(): Promise<void> {
try {
const dataDir = resolveDataDir()
const settingsDir = join(dataDir, "settings")
// Ensure the settings directory exists
const { mkdirSync } = await import("fs")
mkdirSync(settingsDir, { recursive: true })
// Write an empty MCP settings file
const emptyPath = join(settingsDir, "cline_mcp_settings_empty.json")
writeFileSync(emptyPath, JSON.stringify({ mcpServers: {} }))
// Point the SDK to the empty settings file
process.env.CLINE_MCP_SETTINGS_PATH = emptyPath
Logger.log(`[VscodeSessionHost] Empty MCP settings: ${emptyPath}`)
} catch (error) {
Logger.warn("[VscodeSessionHost] Failed to create empty MCP settings:", error)
}
}
+129
View File
@@ -0,0 +1,129 @@
import type { ExtensionState } from "@shared/ExtensionMessage"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { MessageTranslatorState } from "./message-translator"
import { pushMessageToWebview, WebviewGrpcBridge } from "./webview-grpc-bridge"
// Mock the gRPC streaming functions
vi.mock("@core/controller/ui/subscribeToPartialMessage", () => ({
sendPartialMessageEvent: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("@core/controller/state/subscribeToState", () => ({
sendStateUpdate: vi.fn().mockResolvedValue(undefined),
}))
// Mock the proto conversion
vi.mock("@shared/proto-conversions/cline-message", () => ({
convertClineMessageToProto: vi.fn((msg: Record<string, unknown>) => ({
ts: msg.ts,
type: msg.type === "ask" ? 1 : 2,
ask: 0,
say: 0,
text: (msg.text as string) ?? "",
reasoning: (msg.reasoning as string) ?? "",
images: [],
files: [],
partial: (msg.partial as boolean) ?? false,
})),
}))
describe("WebviewGrpcBridge", () => {
let bridge: WebviewGrpcBridge
let translatorState: MessageTranslatorState
beforeEach(() => {
translatorState = new MessageTranslatorState()
bridge = new WebviewGrpcBridge(translatorState)
vi.clearAllMocks()
})
describe("createListener", () => {
it("should return a function", () => {
const listener = bridge.createListener()
expect(typeof listener).toBe("function")
})
it("should push messages through the partial message stream", async () => {
const { sendPartialMessageEvent } = await import("@core/controller/ui/subscribeToPartialMessage")
const listener = bridge.createListener()
const messages = [{ ts: 1, type: "say" as const, say: "text" as const, text: "hello", partial: false }]
const event = { type: "status", payload: { sessionId: "s1", status: "running" } }
// biome-ignore lint/suspicious/noExplicitAny: test-only event type
listener(messages, event as any)
// Wait for async operations
await new Promise((resolve) => setTimeout(resolve, 10))
expect(sendPartialMessageEvent).toHaveBeenCalledTimes(1)
})
it("should push multiple messages through the stream", async () => {
const { sendPartialMessageEvent } = await import("@core/controller/ui/subscribeToPartialMessage")
const listener = bridge.createListener()
const messages = [
{ ts: 1, type: "say" as const, say: "text" as const, text: "first", partial: false },
{ ts: 2, type: "say" as const, say: "tool" as const, text: "tool call", partial: false },
]
const event = { type: "status", payload: { sessionId: "s1", status: "running" } }
// biome-ignore lint/suspicious/noExplicitAny: test-only event type
listener(messages, event as any)
await new Promise((resolve) => setTimeout(resolve, 10))
expect(sendPartialMessageEvent).toHaveBeenCalledTimes(2)
})
})
describe("pushStateUpdateFromController", () => {
it("should push state from the provided getter", async () => {
const { sendStateUpdate } = await import("@core/controller/state/subscribeToState")
const mockState = { version: "1.0.0", mode: "act" } as unknown as ExtensionState
await bridge.pushStateUpdateFromController(async () => mockState)
expect(sendStateUpdate).toHaveBeenCalledWith(mockState)
})
it("should handle errors from the state getter", async () => {
const { sendStateUpdate } = await import("@core/controller/state/subscribeToState")
const errorGetter = async () => {
throw new Error("state error")
}
await bridge.pushStateUpdateFromController(errorGetter)
expect(sendStateUpdate).not.toHaveBeenCalled()
})
})
})
describe("pushMessageToWebview", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("should push a single message to the webview", async () => {
const { sendPartialMessageEvent } = await import("@core/controller/ui/subscribeToPartialMessage")
const message = { ts: 1, type: "say" as const, say: "text" as const, text: "hello", partial: false }
await pushMessageToWebview(message)
expect(sendPartialMessageEvent).toHaveBeenCalledTimes(1)
})
it("should handle errors gracefully", async () => {
const { sendPartialMessageEvent } = await import("@core/controller/ui/subscribeToPartialMessage")
// biome-ignore lint/suspicious/noExplicitAny: mock method not in type
;(sendPartialMessageEvent as any).mockRejectedValueOnce(new Error("stream error"))
const message = { ts: 1, type: "say" as const, say: "text" as const, text: "hello", partial: false }
// Should not throw
await pushMessageToWebview(message)
})
})
+163
View File
@@ -0,0 +1,163 @@
// Replaces classic message streaming from src/core/task/index.ts (see origin/main)
//
// Bridges SDK session events to the webview's gRPC streaming subscriptions.
// When the SDK emits session events (text chunks, tool calls, etc.), this
// module translates them to proto ClineMessages and pushes them through
// the existing subscribeToPartialMessage and subscribeToState streams.
//
// This is the "thunking layer" — the webview continues to receive gRPC-shaped
// messages, but the source is now the SDK instead of the classic Task.
import type { CoreSessionEvent } from "@clinebot/core"
import { sendStateUpdate } from "@core/controller/state/subscribeToState"
import { sendPartialMessageEvent } from "@core/controller/ui/subscribeToPartialMessage"
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
import type { ClineMessage as ProtoClineMessage } from "@shared/proto/cline/ui"
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
import { Logger } from "@shared/services/Logger"
import type { MessageTranslatorState } from "./message-translator"
import type { SessionEventListener } from "./SdkController"
/**
* Manages the bridge between SDK session events and webview gRPC streams.
*
* When the SDK emits events, this bridge:
* 1. Translates them to ClineMessage[] via the message translator
* 2. Converts each ClineMessage to proto format
* 3. Pushes them through the subscribeToPartialMessage gRPC stream
* 4. Pushes state updates through the subscribeToState gRPC stream
* on significant events (turn complete, session ended)
*
* The bridge needs access to the controller's getStateToPostToWebview()
* method to push state updates that include the current task's messages
* and task history. Without this, state updates would have empty messages.
*/
export class WebviewGrpcBridge {
/** Function to get the full ExtensionState from the controller */
private getStateFn?: () => Promise<import("@shared/ExtensionMessage").ExtensionState>
constructor(_translatorState: MessageTranslatorState) {
// translatorState is kept as a constructor param for API compatibility
// but translation is done in SdkController, not in the bridge
}
/**
* Set the function used to get ExtensionState for state updates.
* This should be called after the controller is fully initialized,
* passing `controller.getStateToPostToWebview.bind(controller)`.
*/
setGetStateFn(fn: () => Promise<import("@shared/ExtensionMessage").ExtensionState>): void {
this.getStateFn = fn
}
/**
* Create a SessionEventListener that bridges events to the webview.
* This is passed to SdkController.onSessionEvent().
*/
createListener(): SessionEventListener {
return (messages: ClineMessage[], event: CoreSessionEvent) => {
this.handleSessionEvent(messages, event)
}
}
/**
* Handle a session event by pushing translated messages to webview streams.
*
* NOTE: The messages are already translated by SdkController.handleSessionEvent().
* We do NOT re-translate here — that would double-emit messages and corrupt
* the MessageTranslatorState (e.g., state.reset() on iteration_start would
* clear streaming timestamps that the first translation used).
*/
private handleSessionEvent(messages: ClineMessage[], event: CoreSessionEvent): void {
// Push each translated message through the partial message stream
for (const message of messages) {
this.pushPartialMessage(message)
}
// Check if we need to push a state update based on event type.
// Do NOT call translateSessionEvent() again — the messages are already
// translated. Just check the raw event type for state update triggers.
const needsStateUpdate =
event.type === "ended" ||
(event.type === "agent_event" && (event.payload.event.type === "done" || event.payload.event.type === "error"))
if (needsStateUpdate) {
// Push state update asynchronously — don't block the event stream
this.pushStateUpdate().catch((err) => {
Logger.error("[WebviewGrpcBridge] Failed to push state update:", err)
})
}
}
/**
* Push a ClineMessage to the webview via the subscribeToPartialMessage stream.
*/
private async pushPartialMessage(message: ClineMessage): Promise<void> {
try {
const protoMessage: ProtoClineMessage = convertClineMessageToProto(message)
await sendPartialMessageEvent(protoMessage)
} catch (error) {
Logger.error("[WebviewGrpcBridge] Failed to push partial message:", error)
}
}
/**
* Push a state update to the webview via the subscribeToState stream.
* This is called on significant events (turn complete, session ended).
*
* Uses the controller's getStateToPostToWebview() when available,
* which includes the current task's messages and task history.
* Falls back to a minimal state update without task data.
*/
private async pushStateUpdate(): Promise<void> {
try {
if (this.getStateFn) {
// Use the controller's getStateToPostToWebview() which
// includes messages, currentTaskItem, and task history
const state = await this.getStateFn()
await sendStateUpdate(state)
} else {
// Fallback: build a minimal state without task data
const { getStateToPostToWebview } = await import("@core/controller/state/getStateToPostToWebview")
const { StateManager } = await import("@core/storage/StateManager")
const stateManager = StateManager.get()
const state = await getStateToPostToWebview({
task: undefined,
stateManager,
mcpHub: undefined,
backgroundCommandRunning: false,
backgroundCommandTaskId: undefined,
})
await sendStateUpdate(state)
}
} catch (error) {
Logger.error("[WebviewGrpcBridge] Failed to push state update:", error)
}
}
/**
* Push a state update using the controller's getStateToPostToWebview method.
* This is the preferred way when the controller is available.
*/
async pushStateUpdateFromController(getState: () => Promise<ExtensionState>): Promise<void> {
try {
const state = await getState()
await sendStateUpdate(state)
} catch (error) {
Logger.error("[WebviewGrpcBridge] Failed to push state update from controller:", error)
}
}
}
/**
* Standalone function to push a ClineMessage to the webview.
* Useful for one-off messages outside the bridge's event loop.
*/
export async function pushMessageToWebview(message: ClineMessage): Promise<void> {
try {
const protoMessage: ProtoClineMessage = convertClineMessageToProto(message)
await sendPartialMessageEvent(protoMessage)
} catch (error) {
Logger.error("[WebviewGrpcBridge] Failed to push message to webview:", error)
}
}
+115
View File
@@ -94,6 +94,18 @@ export class McpHub {
// Callback for sending notifications to active task
private notificationCallback?: (serverName: string, level: string, message: string) => void
// Callback for notifying when the MCP tool list changes (servers added/removed/reconnected).
// Used by SdkController to restart the SDK session with updated tools.
private toolListChangeCallback?: () => void
// Fingerprint of the last tool list snapshot, used to detect actual tool list changes
// vs. mere status updates (e.g., error messages appended).
private lastToolFingerprint = ""
// Debounce timer for tool list change checks. When a server connects,
// notifyWebviewOfServerChanges() fires multiple times in quick succession
// (status change, tools discovered, etc.). Without debouncing, the callback
// fires multiple times causing duplicate messages (S6-28).
private toolListChangeDebounceTimer?: ReturnType<typeof setTimeout>
constructor(
getMcpServersPath: () => Promise<string>,
getSettingsDirectoryPath: () => Promise<string>,
@@ -1109,6 +1121,9 @@ export class McpHub {
await sendMcpServersUpdate({
mcpServers: convertMcpServersToProtoMcpServers(sortedServers),
})
// Check if the tool list actually changed and notify SDK controller if so
this.checkToolListChanged()
}
async sendLatestMcpServers() {
@@ -1592,6 +1607,106 @@ export class McpHub {
//Logger.log("[MCP Debug] Notification callback cleared")
}
/**
* Set a callback that fires when the MCP tool list changes.
*
* The callback is invoked only when the set of available tools actually
* changes (servers added/removed, tools discovered/lost), NOT on mere
* status updates (error messages, reconnect attempts).
*
* Used by SdkController to restart the SDK session with updated tools
* when MCP servers change mid-session.
*/
setToolListChangeCallback(callback: () => void): void {
this.toolListChangeCallback = callback
// Initialize the fingerprint so the first real change is detected
this.lastToolFingerprint = this.computeToolFingerprint()
}
/**
* Clear the tool list change callback.
*/
clearToolListChangeCallback(): void {
this.toolListChangeCallback = undefined
}
/**
* Compute a fingerprint of the current tool list.
*
* The fingerprint is a sorted, deterministic string of
* "serverName:toolName" pairs for all connected, non-disabled servers.
* Changes to this fingerprint indicate that the agent's available
* tool set has changed and a session restart may be needed.
*/
computeToolFingerprint(): string {
const entries: string[] = []
for (const conn of this.connections) {
if (conn.server.disabled || conn.server.status !== "connected") {
continue
}
for (const tool of conn.server.tools ?? []) {
entries.push(`${conn.server.name}:${tool.name}`)
}
}
entries.sort()
return entries.join("|")
}
/**
* Check if the tool list has changed and fire the callback if so.
* Called internally after server connection changes settle.
*
* Debounced: when a server connects, notifyWebviewOfServerChanges()
* fires multiple times in quick succession (status change → tools
* discovered → etc.). Without debouncing, the callback fires for
* each intermediate state, causing duplicate messages (S6-28).
* The 300ms debounce coalesces these into a single callback.
*/
private checkToolListChanged(): void {
if (!this.toolListChangeCallback) {
return
}
// Quick-check: if the fingerprint hasn't changed, skip the debounce entirely.
// This avoids scheduling timers for the many notifyWebviewOfServerChanges()
// calls that don't actually change the tool list (e.g., error messages).
const currentFingerprint = this.computeToolFingerprint()
if (currentFingerprint === this.lastToolFingerprint) {
return
}
// Fingerprint changed — debounce to coalesce rapid-fire changes
if (this.toolListChangeDebounceTimer) {
clearTimeout(this.toolListChangeDebounceTimer)
}
this.toolListChangeDebounceTimer = setTimeout(() => {
this.toolListChangeDebounceTimer = undefined
this.fireToolListChangeIfNeeded()
}, 300)
}
/**
* Fire the tool list change callback if the fingerprint has changed.
* Called after the debounce timer expires.
*/
private fireToolListChangeIfNeeded(): void {
if (!this.toolListChangeCallback) {
return
}
const newFingerprint = this.computeToolFingerprint()
if (newFingerprint !== this.lastToolFingerprint) {
Logger.log(
`[McpHub] Tool list changed: "${this.lastToolFingerprint.substring(0, 80)}" → "${newFingerprint.substring(0, 80)}"`,
)
this.lastToolFingerprint = newFingerprint
try {
this.toolListChangeCallback()
} catch (error) {
Logger.error("[McpHub] Error in toolListChangeCallback:", error)
}
}
}
/**
* Initiates OAuth flow for a server
* Opens browser to authorization URL
@@ -0,0 +1,264 @@
/**
* Tests for McpHub's tool list change detection and callback mechanism.
*
* These tests verify that:
* 1. computeToolFingerprint() produces deterministic fingerprints
* 2. The toolListChangeCallback fires only when the tool list actually changes
* 3. The callback does NOT fire on mere status updates (error messages, etc.)
*
* We avoid importing McpHub directly (too many transitive deps for unit tests).
* Instead we extract the pure logic and test it in isolation.
*/
import { describe, it } from "mocha"
import "should"
import sinon from "sinon"
// ---------------------------------------------------------------------------
// Extract the pure logic from McpHub for testing
// (These mirror the implementations in McpHub.ts exactly)
// ---------------------------------------------------------------------------
interface MinimalConnection {
server: {
name: string
status: string
disabled?: boolean
tools?: Array<{ name: string }>
}
}
function computeToolFingerprint(connections: MinimalConnection[]): string {
const entries: string[] = []
for (const conn of connections) {
if (conn.server.disabled || conn.server.status !== "connected") {
continue
}
for (const tool of conn.server.tools ?? []) {
entries.push(`${conn.server.name}:${tool.name}`)
}
}
entries.sort()
return entries.join("|")
}
/**
* Simulates the McpHub's checkToolListChanged logic.
*/
function createToolListChangeTracker() {
let lastFingerprint = ""
let callback: (() => void) | undefined
return {
setCallback(cb: () => void, connections: MinimalConnection[]) {
callback = cb
lastFingerprint = computeToolFingerprint(connections)
},
clearCallback() {
callback = undefined
},
check(connections: MinimalConnection[]) {
if (!callback) return
const newFingerprint = computeToolFingerprint(connections)
if (newFingerprint !== lastFingerprint) {
lastFingerprint = newFingerprint
try {
callback()
} catch {
// Errors in callback are swallowed (logged in production)
}
}
},
}
}
function makeConnection(name: string, status: string, tools: string[], disabled = false): MinimalConnection {
return {
server: {
name,
status,
disabled,
tools: tools.map((t) => ({ name: t })),
},
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("McpHub tool list change detection", () => {
describe("computeToolFingerprint", () => {
it("should return empty string when no servers are connected", () => {
computeToolFingerprint([]).should.equal("")
})
it("should include tools from connected, non-disabled servers", () => {
const connections = [makeConnection("server-a", "connected", ["tool1", "tool2"])]
const fp = computeToolFingerprint(connections)
fp.should.containEql("server-a:tool1")
fp.should.containEql("server-a:tool2")
})
it("should exclude tools from disconnected servers", () => {
const connections = [
makeConnection("server-a", "connected", ["tool1"]),
makeConnection("server-b", "disconnected", ["tool2"]),
]
const fp = computeToolFingerprint(connections)
fp.should.containEql("server-a:tool1")
fp.should.not.containEql("server-b:tool2")
})
it("should exclude tools from disabled servers", () => {
const connections = [makeConnection("server-a", "connected", ["tool1"], true)]
computeToolFingerprint(connections).should.equal("")
})
it("should exclude tools from connecting servers", () => {
const connections = [makeConnection("server-a", "connecting", ["tool1"])]
computeToolFingerprint(connections).should.equal("")
})
it("should produce sorted, deterministic output regardless of insertion order", () => {
const connections1 = [
makeConnection("z-server", "connected", ["b-tool", "a-tool"]),
makeConnection("a-server", "connected", ["z-tool"]),
]
const connections2 = [
makeConnection("a-server", "connected", ["z-tool"]),
makeConnection("z-server", "connected", ["a-tool", "b-tool"]),
]
computeToolFingerprint(connections1).should.equal(computeToolFingerprint(connections2))
})
it("should produce different fingerprints for different tool sets", () => {
const fp1 = computeToolFingerprint([makeConnection("s", "connected", ["tool1"])])
const fp2 = computeToolFingerprint([makeConnection("s", "connected", ["tool2"])])
fp1.should.not.equal(fp2)
})
})
describe("tool list change callback", () => {
it("should fire callback when tool list changes", () => {
const tracker = createToolListChangeTracker()
const callback = sinon.stub()
// Set callback with initial empty state
tracker.setCallback(callback, [])
callback.called.should.be.false()
// Add a server with tools — fingerprint changes
const connections = [makeConnection("server-a", "connected", ["tool1"])]
tracker.check(connections)
callback.calledOnce.should.be.true()
})
it("should NOT fire callback when fingerprint is unchanged", () => {
const connections = [makeConnection("server-a", "connected", ["tool1"])]
const tracker = createToolListChangeTracker()
const callback = sinon.stub()
tracker.setCallback(callback, connections)
// Check again with same state — should not fire
tracker.check(connections)
callback.called.should.be.false()
})
it("should fire callback when a server disconnects (tools lost)", () => {
const connections = [makeConnection("server-a", "connected", ["tool1"])]
const tracker = createToolListChangeTracker()
const callback = sinon.stub()
tracker.setCallback(callback, connections)
// Simulate server disconnect
connections[0].server.status = "disconnected"
tracker.check(connections)
callback.calledOnce.should.be.true()
})
it("should fire callback when new tools are added to a server", () => {
const connections = [makeConnection("server-a", "connected", ["tool1"])]
const tracker = createToolListChangeTracker()
const callback = sinon.stub()
tracker.setCallback(callback, connections)
// Add a new tool
connections[0].server.tools!.push({ name: "tool2" })
tracker.check(connections)
callback.calledOnce.should.be.true()
})
it("should fire callback when a new server connects", () => {
const connections = [makeConnection("server-a", "connected", ["tool1"])]
const tracker = createToolListChangeTracker()
const callback = sinon.stub()
tracker.setCallback(callback, connections)
// Add a new server
connections.push(makeConnection("server-b", "connected", ["tool2"]))
tracker.check(connections)
callback.calledOnce.should.be.true()
})
it("should NOT fire callback when no callback is set", () => {
const tracker = createToolListChangeTracker()
const connections = [makeConnection("server-a", "connected", ["tool1"])]
// No callback set — should not throw
tracker.check(connections)
})
it("should handle callback errors gracefully", () => {
const tracker = createToolListChangeTracker()
const callback = sinon.stub().throws(new Error("callback error"))
tracker.setCallback(callback, [])
const connections = [makeConnection("server-a", "connected", ["tool1"])]
// Should not throw even though callback throws
;(() => tracker.check(connections)).should.not.throw()
})
it("should stop firing after clearCallback", () => {
const tracker = createToolListChangeTracker()
const callback = sinon.stub()
tracker.setCallback(callback, [])
tracker.clearCallback()
const connections = [makeConnection("server-a", "connected", ["tool1"])]
tracker.check(connections)
callback.called.should.be.false()
})
it("should track cumulative changes correctly", () => {
const tracker = createToolListChangeTracker()
const callback = sinon.stub()
tracker.setCallback(callback, [])
// First change: add server
const connections = [makeConnection("server-a", "connected", ["tool1"])]
tracker.check(connections)
callback.callCount.should.equal(1)
// No change: same state
tracker.check(connections)
callback.callCount.should.equal(1)
// Second change: add tool
connections[0].server.tools!.push({ name: "tool2" })
tracker.check(connections)
callback.callCount.should.equal(2)
// Third change: server disconnects
connections[0].server.status = "disconnected"
tracker.check(connections)
callback.callCount.should.equal(3)
})
})
})
+17
View File
@@ -0,0 +1,17 @@
declare module "ws" {
class WebSocket {
constructor(url: string)
on(event: "open", listener: () => void): this
on(event: "error", listener: (error: Error) => void): this
on(event: "close", listener: () => void): this
on(event: "message", listener: (data: WebSocket.Data) => void): this
send(data: string): void
close(): void
}
namespace WebSocket {
export type Data = string | Buffer | ArrayBuffer | Buffer[]
}
export default WebSocket
}
+20
View File
@@ -0,0 +1,20 @@
import path from "node:path"
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["src/sdk/**/*.test.ts"],
environment: "node",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@core": path.resolve(__dirname, "src/core"),
"@integrations": path.resolve(__dirname, "src/integrations"),
"@services": path.resolve(__dirname, "src/services"),
"@shared": path.resolve(__dirname, "src/shared"),
"@utils": path.resolve(__dirname, "src/utils"),
"@packages": path.resolve(__dirname, "src/packages"),
},
},
})
@@ -24,20 +24,17 @@ const createReasoningMessage = (ts: number, text: string): ClineMessage => ({
})
describe("groupLowStakesTools", () => {
it("ignores text that arrives after a low-stakes tool group has started", () => {
it("keeps text that arrives after a low-stakes tool group by finalizing the group first", () => {
const grouped = groupLowStakesTools([
createTextMessage(1, "Initial text"),
createToolMessage(2, "readFile"),
createTextMessage(3, "Late text that should be ignored"),
createTextMessage(3, "Post-tool summary text"),
])
expect(grouped).toHaveLength(2)
expect(grouped).toHaveLength(3)
expect(grouped[0]).toMatchObject({ type: "say", say: "text", text: "Initial text" })
expect(isToolGroup(grouped[1])).toBe(true)
if (isToolGroup(grouped[1])) {
expect(grouped[1].every((message) => message.say !== "text")).toBe(true)
}
expect(grouped[2]).toMatchObject({ type: "say", say: "text", text: "Post-tool summary text" })
})
it("keeps text when no low-stakes tool group is active", () => {
@@ -822,11 +822,12 @@ export function groupLowStakesTools(groupedMessages: (ClineMessage | ClineMessag
continue
}
// Text - once a tool group is active, ignore additional text so it
// doesn't continue mutating the text row rendered above the group.
// Text - if a low-stakes tool group is active, finalize it first,
// then render the text as a normal chat row. This ensures post-tool
// summaries (common in SDK/native-tool-call flows) are visible.
if (messageType === "text") {
if (hasTools) {
continue
commitToolGroup()
}
flushPending()
result.push(message)
+5
View File
@@ -54,6 +54,11 @@ declare global {
// Initialize the vscode API if available
const vsCodeApi = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : null
// Expose the VSCode API for debug harness access
if (vsCodeApi && typeof window !== "undefined") {
;(window as any).__clineVsCodeApi = vsCodeApi
}
// Implementations for post message handling
const postMessageStrategies: Record<string, PostMessageFunction> = {
vscode: (message: any) => {
@@ -518,11 +518,15 @@ export const ExtensionStateContextProvider: React.FC<{
// worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock
const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === partialMessage.ts)
if (lastIndex !== -1) {
// Update existing message in-place (classic streaming update)
const newClineMessages = [...prevState.clineMessages]
newClineMessages[lastIndex] = partialMessage
return { ...prevState, clineMessages: newClineMessages }
}
return prevState
// No existing message with this timestamp — append it.
// This happens in the SDK migration where messages arrive
// via the partial message stream before the state update.
return { ...prevState, clineMessages: [...prevState.clineMessages, partialMessage] }
})
} catch (error) {
console.error("Failed to process partial message:", error, protoMessage)