Skip to content

Conversation

@nikomatsakis
Copy link
Member

Summary

This PR adds experimental support for exposing ACP agents as VS Code Language Models via the LanguageModelChatProvider API (VS Code 1.104+). This allows ACP agents to appear in VS Code's model picker and be used by extensions like GitHub Copilot.

Status: Experimental, disabled by default. Enable with symposium.enableExperimentalLM: true.

What's Included

Core Language Model Provider

  • Rust backend (vscodelm subcommand): Handles session management, message history, and agent communication
  • TypeScript adapter: Thin layer that serializes VS Code LM API calls to JSON-RPC over stdio
  • Session management: Committed/provisional history model to bridge VS Code's stateless API to ACP's stateful sessions

Tool Bridging

  • Agent-internal tools: Permission requests surfaced via symposium-agent-action tool
  • VS Code-provided tools: Synthetic MCP server bridges VS Code tools to agents
  • Cancellation handling: Detects approval/rejection via history matching

Agent Registry Integration

  • Exposes one language model per registered ACP agent
  • Auto-install support for agents not yet installed
  • Settings panel shows all registry agents

Other Changes

  • Removed Codex and Gemini from built-in agents
  • Added vscodelm_cli example for debugging tool invocation
  • Various refactoring for cleaner async patterns (futures-concurrency race, etc.)

Known Issues

  1. Multiple VS Code tools cause invocation failures: A single isolated tool works, but when multiple VS Code-provided tools are bridged, the model doesn't invoke them properly. Root cause unknown.

  2. Context collision: VS Code LM consumers inject their own context (project details, editor state). ACP agents like Claude Code also inject context. These competing layers may confuse the model.

Open Question

The LM API may not be the right abstraction for wrapping full ACP agents. It seems better suited for raw model access rather than agents with their own context management and tool orchestration.

Documentation

nikomatsakis and others added 30 commits January 6, 2026 19:19
Adds support for exposing Symposium as a VS Code Language Model via the
LanguageModelChatProvider API (VS Code 1.104+). This allows Symposium to
appear in VS Code's model picker and be used by Copilot and other
extensions that consume the Language Model API.

Components:
- Rust: vscodelm subcommand in symposium-acp-agent that speaks JSON-RPC
- TypeScript: LanguageModelChatProvider that bridges to the Rust process
- Design doc: md/design/vscode-extension/lm-provider.md

The prototype uses Eliza for responses to validate the plumbing before
integrating with real ACP agents.

Co-authored-by: Claude <[email protected]>
Replace hand-rolled JSON-RPC handling with sacp's typed message
infrastructure:

- Define VsCodePeer/LmBackendPeer peers and corresponding links
- Define message types using JrRequest/JrNotification derives
- Use MatchMessage for request handling
- Use sacp's connection builder to serve on stdio

This aligns with how the rest of Symposium handles JSON-RPC and
provides better type safety.

Co-authored-by: Claude <[email protected]>
Eliza is owned by a single handler instance, so no synchronization needed.
Split MatchMessage handling to allow &mut self access for the response
handler.

Co-authored-by: Claude <[email protected]>
- LmBackend now implements Component<LmBackendToVsCode>
- Add test_provide_info: verifies model info response
- Add test_provide_token_count: verifies token counting heuristic
- Add test_chat_response: verifies streaming response with notifications

Tests use sacp's Channel and into_server() for in-process testing,
avoiding the need for manual JSON-RPC message construction.

Co-authored-by: Claude <[email protected]>
- Add expect-test to workspace dependencies
- Refactor tests to use expect! snapshots instead of assertions
- Add LmBackend::new_deterministic() for reproducible test results

Co-authored-by: Claude <[email protected]>
- Add proper instanceof checks for LanguageModelTextPart,
  LanguageModelToolCallPart, and LanguageModelToolResultPart
- Create MessageContentPart discriminated union type
- Skip known-but-undocumented VS Code/Copilot internal parts
  (cache_control, stateful_marker) with debug logging
- Log truly unknown part types as errors with full JSON
- Fixes [object Object] corruption in message serialization

This enables clean message history comparison for session matching.

Co-authored-by: Claude <[email protected]>
Implements session management for the VS Code Language Model Provider using
the Tokio actor pattern. This enables session continuity across multiple
requests from VS Code, which sends full message history with each request.

Key changes:
- Add session_actor.rs with SessionActor handle and run loop
- Refactor LmBackendHandler to maintain Vec<SessionActor>
- Session matching via message history prefix comparison
- Clean cancellation via channel closure (drop reply_rx)
- Actor streams response parts back through one-shot reply channel

The session manager finds the session with the longest matching prefix,
computes new messages, and only sends those to the actor. This avoids
reprocessing the entire conversation on each request.

Co-authored-by: Claude <[email protected]>
Each session actor now has a unique UUID for tracing. Logs include:
- Session creation with UUID
- Incoming message counts per request
- Eliza responses with user input
- Cancellation events
- Session shutdown

Co-authored-by: Claude <[email protected]>
- Add McpServer field to ProvideResponseRequest params
- Introduce AgentDefinition enum (Eliza for testing, McpServer for real agents)
- Session actor now spawns ACP agent using sacp Component infrastructure
- Replace old Eliza-based session loop with ACP session management
- Remove deterministic Eliza tests (require different test infrastructure now)

Co-authored-by: Claude <[email protected]>
The Stdio variant of McpServer uses #[serde(untagged)] so it serializes
as a flat object without a wrapper. Updated TypeScript interface to match.

Also added a test to verify the serialization format.

Co-authored-by: Claude <[email protected]>
- Document the McpServerStdio format for agent configuration
- Update implementation status to reflect completed work
- Remove 'Full ACP agent integration' from future work (now done)

Co-authored-by: Claude <[email protected]>
The agent field now accepts either:
- { eliza: { deterministic?: boolean } } - for in-process testing
- { mcp_server: { name, command, args, env } } - for external ACP agents

This allows the Language Model API to use both the built-in Eliza agent
for testing and real external agents configured in VS Code settings.

Co-authored-by: Claude <[email protected]>
Instead of sending the 'eliza' variant to the backend, the TypeScript
extension now always sends 'mcp_server' with the resolved binary path.
For symposium builtins like Eliza, this uses getConductorCommand() to
get the embedded binary path and adds the subcommand as the first arg.

The Rust protocol still supports the 'eliza' variant for direct testing.

Co-authored-by: Claude <[email protected]>
- New chapter: lm-tool-bridging.md covering:
  - VS Code-provided tools via synthetic MCP server
  - Agent-internal tools with permission flow
  - Handle state machine (Idle, AwaitingToolPermission, AwaitingToolResult)
  - History matching for session continuity
  - Cancellation handling

- New references:
  - vscode-lm-tool-api.md: Tool registration, call flow, confirmation UI
  - vscode-lm-tool-rejection.md: Rejection behavior and limitations

- Updated lm-provider.md to link to new tool bridging chapter

Co-authored-by: Claude <[email protected]>
- Add lm/cancel notification from TypeScript to Rust
- Refactor handler to use SessionData with SessionState enum (Idle/Streaming)
- Make request handler non-blocking by spawning streaming task
- Use futures-concurrency Race to handle cancellation during streaming
- TypeScript sends lm/cancel on CancellationToken fire
- Convert cancellation errors to vscode.CancellationError
- Auto-cancel previous streaming when new request arrives on same session

Co-authored-by: Claude <[email protected]>
…lation

- Switch from tokio::sync channels to futures::channel for cleaner
  stream-based cancellation handling
- Replace race-in-loop pattern with futures-concurrency Merge for
  combining response parts and cancellation signals
- Use unbounded_send() for futures mpsc channels
- Use StreamExt::next() instead of recv() for futures channels
- Pin merged stream for iteration

Co-authored-by: Claude <[email protected]>
Adds support for ACP agents to request permission for internal tools
(like bash, file_edit) through VS Code's Language Model Tool API.

TypeScript side:
- Register symposium-agent-action tool in package.json
- Add AgentActionTool class with prepareInvocation for confirmation UI
- Extend ResponsePart type to include tool_call variant
- Update pendingRequests to handle LanguageModelResponsePart

Rust side:
- Extend ContentPart to support tool_call and tool_result variants
- Add message helpers for checking tool call/result presence
- Handle session/request_permission from agents in session_actor
- Emit ToolCall response parts for symposium-agent-action
- Add AwaitingPermission marker in response stream
- Implement history matching to detect approval/rejection
- Continue streaming after permission approval

The flow:
1. Agent sends session/request_permission
2. Rust emits LanguageModelToolCallPart for symposium-agent-action
3. VS Code shows confirmation dialog via prepareInvocation
4. On next request, history matching detects approval (tool result present)
5. Permission decision sent to actor, streaming continues

Co-authored-by: Claude <[email protected]>
Simplifies the message handling by using MatchMessage pattern instead
of manual match + parse_message calls. This also removes the need for
manual JSON serialization since MatchMessage provides typed request
context.

Co-authored-by: Claude <[email protected]>
Work in progress - does not build.

Changes:
- Remove ResponsePart, use ContentPart for both messages and response streaming
- Unify field names to match VS Code API (toolCallId, toolName, parameters)
- Add send_tool_use with peek/consume pattern (incomplete)
- Document committed/provisional session model in lm-provider.md
- Document tool bridging flows in lm-tool-bridging.md

Next: implement committed/provisional tracking in SessionActor handle

Co-authored-by: Claude <[email protected]>
Refactor the vscodelm module to use an actor-based architecture where:
- HistoryActor owns all session state (committed/provisional history)
- SessionActors send parts back to HistoryActor via mailbox
- JrConnectionCx handler forwards requests to HistoryActor

This solves the problem of record_part needing &mut SessionData access
from within spawned tasks. The HistoryActor processes messages in its
central mailbox loop, providing proper &mut access to session state.

Key changes:
- Add history_actor.rs with HistoryActor, HistoryActorHandle, message types
- Rewrite session_actor.rs to use HistoryActorHandle instead of JrConnectionCx
- Simplify mod.rs handler to forward to HistoryActor (lazy initialized)
- Make role constants pub(crate) for submodule access
- Temporarily disable request_response.rs (needs update for new architecture)

Co-authored-by: Claude <[email protected]>
- Add ToolDefinition, ToolMode, and ChatRequestOptions types on both sides
- TypeScript: properly type options parameter in provideLanguageModelChatResponse
- Rust: add options field to ProvideResponseRequest
- Track has_internal_tool flag per-session to auto-deny permission requests
  when symposium-agent-action tool is not available
- Bundle cancel_rx and has_internal_tool into RequestState struct to ensure
  correct values are used when requests change mid-turn
- Add RequestState::cancellation() helper for cleaner async patterns
- Fix eslint warnings in languageModelProvider.ts and agentRegistry.ts

Co-authored-by: Claude <[email protected]>
The toolCallId is provided by VS Code through the LanguageModelToolCallPart
structure, not as part of the tool input parameters. Removing it from the
required fields fixes the validation error.

Also improved the confirmation UI to show the description from raw_input
if available (e.g., Claude Code's bash tool includes this).

Co-authored-by: Claude <[email protected]>
Add normalize_messages() to coalesce consecutive Text parts in messages.
This ensures our provisional history matches VS Code's format when comparing.

- Normalize incoming messages in handle_vscode_request
- Normalize provisional messages on Complete
- Add debug logging for history match mismatches

Co-authored-by: Claude <[email protected]>
nikomatsakis and others added 29 commits January 6, 2026 19:19
- Fetch registry agents alongside availability checks
- Merge uninstalled registry agents into the agent list
- Auto-install registry agent to settings when selected
- Remove separate 'Add agent from registry' link (no longer needed)

Co-authored-by: Claude <[email protected]>
These are now available from the ACP registry and will be shown
automatically alongside other registry agents.

Co-authored-by: Claude <[email protected]>
Sort by display name in both the settings panel and LM provider.

Co-authored-by: Claude <[email protected]>
Instead of returning the receiver from new(), callers now create
the channel and pass in the sender.

Co-authored-by: Claude <[email protected]>
Use (fut1, fut2, fut3).race() pattern instead of tokio::select! to
avoid subtle cancellation bugs that select can introduce.

Co-authored-by: Claude <[email protected]>
- Take ownership of request_state and return Result<RequestState, Canceled>
- Take ownership of ToolInvocation including the result_tx
- Send result via result_tx inside the function rather than at call site
- Add Canceled marker type for consistency with other similar functions

Co-authored-by: Claude <[email protected]>
Now properly uses the request_state.cancel_rx to abort early if
cancellation occurs while waiting for the tool result from VS Code.

Co-authored-by: Claude <[email protected]>
- Add cancel_tool_invocation() helper to reduce boilerplate
- Destructure ToolInvocation at start of handle_vscode_tool_invocation
- Use Race::race((a, b)).await style for cleaner formatting

Co-authored-by: Claude <[email protected]>
Simplifies the cancellation pattern in race calls from:
  async { let _ = (&mut request_state.cancel_rx).await; Event::Canceled }
to:
  request_state.on_cancel(Event::Canceled)

Co-authored-by: Claude <[email protected]>
The session actor was connecting directly to the agent, which bypassed
MCP-over-ACP negotiation. This meant the synthetic VscodeToolsMcpServer
was never discovered by the agent.

Now we wrap the agent in a Conductor using AgentOnly, which enables
proper MCP-over-ACP negotiation so the agent can discover and use
our VS Code tools MCP server.

Co-authored-by: Claude <[email protected]>
The MCP server needs to be provided by a proxy component in the Conductor's
chain, not by the client connecting to the Conductor. Previously, we were
adding the MCP server via build_session().with_mcp_server() on the ClientToAgent
connection, but the Conductor's MCP bridging happens between the proxy chain
and the agent.

This commit:
- Creates VscodeToolsProxy, a Component<ProxyToConductor> that wraps the
  VscodeToolsMcpServer and provides it via ProxyToConductor::builder()
- Updates run_with_agent to include VscodeToolsProxy in the Conductor's
  proxy chain using the closure-based instantiator
- Moves the MCP server creation to run_with_agent and passes the
  tools_handle and invocation_rx to run_with_cx

The proxy sits in the Conductor's chain between client and agent, making
the synthetic MCP server available to the agent via MCP-over-ACP bridging.

Co-authored-by: Claude <[email protected]>
Changes the --log CLI argument from tracing::Level to String, allowing
flexible filter expressions like 'sacp=debug,symposium=trace' in addition
to simple levels like 'debug'. This makes it easier to do targeted
debugging via the VS Code symposium.agentLogLevel setting.

Co-authored-by: Claude <[email protected]>
Fixes a race condition where the agent would call tools/list before
VS Code had reported its available tools, resulting in an empty tool list.

Changes:
- Peek at first request to get initial tools before creating session
- Add set_initial_tools() to populate tools without sending notification
- Only send tools/list_changed notification when tools actually change
- Extract peek() helper for cleaner stream peeking

Co-authored-by: Claude <[email protected]>
Add in-process integration tests for vscodelm that simulate VS Code
talking to elizacp without spawning binaries:

- test_simple_chat_request: basic chat flow
- test_chat_request_with_tools: tools passed in request
- test_multi_turn_conversation: multi-turn with history
- test_mcp_list_tools: elizacp 'list tools from vscode-tools' command
- test_mcp_invoke_tool: elizacp 'use tool' command triggers ToolCall

Uses expect_test for snapshot-style assertions.

Co-authored-by: Claude <[email protected]>
The assistant message in the conversation history should match what
Eliza actually said in the first turn, not a made-up response.

Co-authored-by: Claude <[email protected]>
When handle_vscode_tool_invocation sends Complete to signal VS Code
to invoke the tool, the history actor drops the old cancel_tx. This
caused the old cancel_rx to fire immediately, incorrectly canceling
the tool invocation.

The fix removes the cancellation race from the peek() call - after
sending Complete, the old cancel channel is meaningless. The new
request will bring its own cancel_rx.

Also adds test_mcp_tool_result_flow to verify the full tool call
round-trip: VS Code triggers tool call, provides result, elizacp
echoes it back.

Co-authored-by: Claude <[email protected]>
The integration tests in vscodelm_mcp_bridging.rs were slow, flaky
(timeouts, signal 101), and didn't add meaningful coverage beyond
what the unit tests already provide. The unit tests exercise the
same vscodelm → conductor → elizacp path including MCP bridge setup,
just with in-process channels instead of stdio.

Co-authored-by: Claude <[email protected]>
Extract tests from mod.rs into tests.rs for better organization.

Co-authored-by: Claude <[email protected]>
When handle_vscode_tool_invocation sends Complete, the history actor
drops the streaming state including cancel_tx. This causes cancel_rx
to resolve, which was incorrectly winning the race against peek() and
returning Canceled.

The fix is to not race against cancel_rx after sending Complete, since
it's stale at that point. Real cancellation is detected via the
next_request.canceled flag, which is set when VS Code sends a request
with mismatched history.

Also adds test_mcp_invoke_tool test that verifies the full tool
invocation flow: sending a tool call to VS Code, receiving a tool
result back, and Eliza continuing with the response.

Co-authored-by: Claude <[email protected]>
Add an interactive CLI that acts as a fake VS Code to debug the
vscodelm tool invocation flow with real Claude Code.

Changes:
- Add ClaudeCode variant to AgentDefinition
- Create lib.rs to expose vscodelm module for examples
- Add vscodelm_cli example with 'average' tool for testing

Usage: cargo run --example vscodelm_cli

Co-authored-by: Claude <[email protected]>
Allows writing traces to a file for debugging:
  cargo run --example vscodelm_cli -- --log-file /tmp/vscodelm.log

Then tail -f the log in another terminal while interacting.

Co-authored-by: Claude <[email protected]>
Claude Code prefixes MCP tool names with `mcp__<server>__<tool>` when
invoking tools. Our VscodeToolsMcpServer was comparing against unprefixed
names stored in the tool list, causing 'tool not found' errors.

Now we strip the `mcp__vscode_tools__` prefix before looking up and
invoking tools.

Co-authored-by: Claude <[email protected]>
- Add experimental banner and current status section to lm-provider.md
- Add Language Model Provider section to implementation-status.md
- Document known issue: multiple VS Code-provided tools cause invocation failures
- Document open question: competing context injection between VS Code LM consumers and ACP agents

Co-authored-by: Claude <[email protected]>
@nikomatsakis nikomatsakis merged commit 0e86585 into symposium-dev:main Jan 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant