Fix ACP auth and Gemini session compatibility
This commit is contained in:
parent
6ebcdd6f5b
commit
98a076cd65
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
build/
|
build/
|
||||||
|
.env
|
||||||
|
|||||||
173
docs/acp-public-validation-2026-04-09.md
Normal file
173
docs/acp-public-validation-2026-04-09.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# ACP Public Validation - 2026-04-09
|
||||||
|
|
||||||
|
This document records the post-deployment public validation for `xworkmate-bridge.svc.plus` and the unified ACP ingress at `acp-server.svc.plus`.
|
||||||
|
|
||||||
|
It is intended as an app-integration reference so future clients use the verified public endpoints and expected JSON-RPC methods.
|
||||||
|
|
||||||
|
## Verified Public Endpoints
|
||||||
|
|
||||||
|
### Bridge root
|
||||||
|
|
||||||
|
- URL: `https://xworkmate-bridge.svc.plus/`
|
||||||
|
- Result: `200 OK`
|
||||||
|
- Body: `xworkmate-bridge is running`
|
||||||
|
|
||||||
|
### ACP public ingress
|
||||||
|
|
||||||
|
The public ACP JSON-RPC endpoint is the `.../acp/rpc` path.
|
||||||
|
|
||||||
|
Do not send JSON-RPC requests to `.../acp` for HTTP clients.
|
||||||
|
|
||||||
|
Verified public HTTP JSON-RPC endpoints:
|
||||||
|
|
||||||
|
- Codex: `https://acp-server.svc.plus/codex/acp/rpc`
|
||||||
|
- OpenCode: `https://acp-server.svc.plus/opencode/acp/rpc`
|
||||||
|
- Gemini: `https://acp-server.svc.plus/gemini/acp/rpc`
|
||||||
|
|
||||||
|
The `.../acp` path remains reserved for WebSocket ACP.
|
||||||
|
|
||||||
|
## Auth Contract
|
||||||
|
|
||||||
|
All verified public ACP HTTP requests used:
|
||||||
|
|
||||||
|
- header: `Authorization: Bearer <INTERNAL_SERVICE_TOKEN>`
|
||||||
|
- header: `Content-Type: application/json`
|
||||||
|
|
||||||
|
Missing bearer auth returns a JSON-RPC error envelope with code `-32001`.
|
||||||
|
|
||||||
|
## Public Validation Results
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
Verified `acp.capabilities` over the public ingress:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "acp.capabilities",
|
||||||
|
"result": {
|
||||||
|
"providers": ["codex", "gemini", "opencode"],
|
||||||
|
"singleAgent": true,
|
||||||
|
"multiAgent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified `session.start` reached the Codex execution layer, but the task failed upstream.
|
||||||
|
|
||||||
|
Observed upstream failure summary:
|
||||||
|
|
||||||
|
- repeated `wss://api.openai.com/v1/responses` `500 Internal Server Error`
|
||||||
|
- final `https://api.openai.com/v1/responses` `401 Unauthorized`
|
||||||
|
- message: `Missing bearer or basic authentication in header`
|
||||||
|
|
||||||
|
### OpenCode
|
||||||
|
|
||||||
|
Verified `acp.capabilities` over the public ingress:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "acp.capabilities",
|
||||||
|
"result": {
|
||||||
|
"providers": ["opencode"],
|
||||||
|
"singleAgent": true,
|
||||||
|
"multiAgent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified `session.start` end to end with prompt `Reply with exactly pong`.
|
||||||
|
|
||||||
|
Observed result:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"provider": "opencode",
|
||||||
|
"output": "pong"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
Verified `acp.capabilities` over the public ingress:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "acp.capabilities",
|
||||||
|
"result": {
|
||||||
|
"providers": ["gemini"],
|
||||||
|
"singleAgent": true,
|
||||||
|
"multiAgent": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Before the compatibility layer landed, the upstream Gemini ACP returned:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "\"Method not found\": session.start"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter has now been updated so `session.start` and `session.message` default to adapter-local prompt compatibility instead of forwarding unsupported upstream methods.
|
||||||
|
|
||||||
|
## App Integration Notes
|
||||||
|
|
||||||
|
### Recommended request shape
|
||||||
|
|
||||||
|
Use JSON-RPC `POST` requests against `.../acp/rpc`.
|
||||||
|
|
||||||
|
For capability discovery:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "cap-1",
|
||||||
|
"method": "acp.capabilities"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For single-agent task execution:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "task-1",
|
||||||
|
"method": "session.start",
|
||||||
|
"params": {
|
||||||
|
"sessionId": "session-1",
|
||||||
|
"threadId": "thread-1",
|
||||||
|
"taskPrompt": "Reply with exactly pong",
|
||||||
|
"workingDirectory": "/tmp",
|
||||||
|
"routing": {
|
||||||
|
"routingMode": "explicit",
|
||||||
|
"explicitExecutionTarget": "singleAgent",
|
||||||
|
"explicitProviderId": "opencode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider-specific notes
|
||||||
|
|
||||||
|
- `opencode` is the currently verified public task path.
|
||||||
|
- `gemini` now depends on the adapter compatibility layer, not an upstream Gemini ACP conversation method.
|
||||||
|
- `codex` public routing is healthy, but task execution requires upstream OpenAI auth to be present in the runtime environment.
|
||||||
|
|
||||||
|
## Codex Runtime Root Cause
|
||||||
|
|
||||||
|
Remote inspection on `jp-xhttp-contabo.svc.plus` showed:
|
||||||
|
|
||||||
|
- `codex-app-server` only had `HOME=/root TERM=xterm-256color NODE_NO_WARNINGS=1`
|
||||||
|
- `/root/.codex` existed, but no auth/config JSON files were present
|
||||||
|
- `codex --version` was `0.117.0`
|
||||||
|
|
||||||
|
That means the deployed Codex runtime was able to start, but it did not have a usable OpenAI auth source.
|
||||||
|
|
||||||
|
For future deployments, the systemd units should provide:
|
||||||
|
|
||||||
|
- `CODEX_HOME`
|
||||||
|
- `OPENAI_API_KEY` when API-key auth is used
|
||||||
|
- optional custom base URL variables when running against a non-default upstream
|
||||||
@ -269,7 +269,7 @@ XWORKMATE_GEMINI_ARGS=--experimental-acp
|
|||||||
XWORKMATE_GEMINI_INIT_PROTOCOL_VERSION=1
|
XWORKMATE_GEMINI_INIT_PROTOCOL_VERSION=1
|
||||||
```
|
```
|
||||||
|
|
||||||
The implemented first version exposes:
|
The implemented adapter exposes:
|
||||||
|
|
||||||
- `POST /acp/rpc`
|
- `POST /acp/rpc`
|
||||||
- `GET /acp` WebSocket ACP
|
- `GET /acp` WebSocket ACP
|
||||||
@ -284,14 +284,19 @@ Supported adapter methods:
|
|||||||
- `gemini.initialize`
|
- `gemini.initialize`
|
||||||
- `gemini.raw`
|
- `gemini.raw`
|
||||||
|
|
||||||
`session.start` and `session.message` currently behave as a shim skeleton:
|
`session.start` and `session.message` now use a compatibility layer by default:
|
||||||
|
|
||||||
- the adapter always initializes Gemini ACP first
|
- the adapter still initializes Gemini ACP first so `acp.capabilities` remains grounded in the real upstream ACP surface
|
||||||
- then it forwards to the configured upstream method
|
- if `GEMINI_ADAPTER_UPSTREAM_METHOD` is unset, session traffic runs through adapter-local prompt mode
|
||||||
- by default, the upstream method name is the same as the incoming bridge method
|
- the adapter keeps session-local history keyed by `sessionId`
|
||||||
- if Gemini returns an upstream error, the adapter converts that into a bridge-compatible `success: false` result payload instead of failing the HTTP transport
|
- `session.start` resets adapter-local history for that session
|
||||||
|
- `session.message` replays prior user turns plus the new turn as one prompt to the Gemini CLI
|
||||||
|
- the adapter returns a bridge-compatible single-agent payload with `output`, `provider`, `mode`, and `upstreamMethod: "prompt"`
|
||||||
|
- `session.close` drops adapter-local state
|
||||||
|
|
||||||
You can override the forwarded method name with:
|
This default exists because the verified Gemini ACP upstream did not expose bridge-compatible `session.start` / `session.message` methods during testing.
|
||||||
|
|
||||||
|
If Gemini ACP later gains a compatible conversation method, you can override the forwarded method name with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export GEMINI_ADAPTER_UPSTREAM_METHOD=your-discovered-gemini-method
|
export GEMINI_ADAPTER_UPSTREAM_METHOD=your-discovered-gemini-method
|
||||||
|
|||||||
@ -101,7 +101,7 @@ func NewServer() *Server {
|
|||||||
queues: make(map[string]chan task),
|
queues: make(map[string]chan task),
|
||||||
gateway: gatewayruntime.NewManager(),
|
gateway: gatewayruntime.NewManager(),
|
||||||
providerCatalog: make(map[string]syncedProvider),
|
providerCatalog: make(map[string]syncedProvider),
|
||||||
authService: service.NewStaticTokenAuthService(""),
|
authService: service.NewStaticTokenAuthService(strings.TrimSpace(shared.EnvOrDefault("ACP_AUTH_TOKEN", ""))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package geminiadapter
|
package geminiadapter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
@ -24,12 +25,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
client rpcClient
|
client rpcClient
|
||||||
authService *service.StaticTokenAuthService
|
authService *service.StaticTokenAuthService
|
||||||
providerID string
|
providerID string
|
||||||
providerLabel string
|
providerLabel string
|
||||||
allowedOrigins []string
|
allowedOrigins []string
|
||||||
upstreamMethod string
|
upstreamMethod string
|
||||||
|
sessionRunner func(context.Context, string, string, string) (string, error)
|
||||||
|
sessionsMu sync.Mutex
|
||||||
|
sessions map[string]*adapterSession
|
||||||
}
|
}
|
||||||
|
|
||||||
var adapterWSUpgrader = websocket.Upgrader{
|
var adapterWSUpgrader = websocket.Upgrader{
|
||||||
@ -40,6 +44,14 @@ var adapterWSUpgrader = websocket.Upgrader{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adapterSession struct {
|
||||||
|
history []string
|
||||||
|
model string
|
||||||
|
workingDirectory string
|
||||||
|
lastOutput string
|
||||||
|
lastUpstreamMethod string
|
||||||
|
}
|
||||||
|
|
||||||
func Serve(args []string) error {
|
func Serve(args []string) error {
|
||||||
flags := flag.NewFlagSet("gemini-acp-adapter", flag.ExitOnError)
|
flags := flag.NewFlagSet("gemini-acp-adapter", flag.ExitOnError)
|
||||||
listen := flags.String(
|
listen := flags.String(
|
||||||
@ -98,6 +110,16 @@ func NewServer(client rpcClient) *Server {
|
|||||||
providerLabel: strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_PROVIDER_LABEL", defaultLabel)),
|
providerLabel: strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_PROVIDER_LABEL", defaultLabel)),
|
||||||
allowedOrigins: parseAllowedOrigins(strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*"))),
|
allowedOrigins: parseAllowedOrigins(strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*"))),
|
||||||
upstreamMethod: strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_UPSTREAM_METHOD", "")),
|
upstreamMethod: strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_UPSTREAM_METHOD", "")),
|
||||||
|
sessionRunner: func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||||
|
return shared.RunProviderCommand(
|
||||||
|
ctx,
|
||||||
|
defaultProviderID,
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
workingDirectory,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
sessions: make(map[string]*adapterSession),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,7 +210,8 @@ func (s *Server) handleRequest(request shared.RPCRequest) map[string]any {
|
|||||||
case "session.cancel":
|
case "session.cancel":
|
||||||
return map[string]any{"accepted": true, "cancelled": false}
|
return map[string]any{"accepted": true, "cancelled": false}
|
||||||
case "session.close":
|
case "session.close":
|
||||||
return map[string]any{"accepted": true, "closed": true}
|
sessionID := strings.TrimSpace(shared.StringArg(request.Params, "sessionId", ""))
|
||||||
|
return map[string]any{"accepted": true, "closed": s.closeSession(sessionID)}
|
||||||
case "gemini.initialize":
|
case "gemini.initialize":
|
||||||
return s.handleInitialize()
|
return s.handleInitialize()
|
||||||
case "gemini.raw":
|
case "gemini.raw":
|
||||||
@ -275,9 +298,13 @@ func (s *Server) handleSessionRequest(method string, params map[string]any) map[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
upstreamMethod := s.upstreamMethod
|
upstreamMethod := s.upstreamMethod
|
||||||
if upstreamMethod == "" {
|
if upstreamMethod != "" {
|
||||||
upstreamMethod = strings.TrimSpace(method)
|
return s.handleConfiguredUpstreamSessionRequest(upstreamMethod, params)
|
||||||
}
|
}
|
||||||
|
return s.handleCompatSessionRequest(method, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleConfiguredUpstreamSessionRequest(upstreamMethod string, params map[string]any) map[string]any {
|
||||||
response, err := s.client.Call(upstreamMethod, params)
|
response, err := s.client.Call(upstreamMethod, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
@ -317,6 +344,130 @@ func (s *Server) handleSessionRequest(method string, params map[string]any) map[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCompatSessionRequest(method string, params map[string]any) map[string]any {
|
||||||
|
if s.sessionRunner == nil {
|
||||||
|
return map[string]any{
|
||||||
|
"success": false,
|
||||||
|
"provider": s.providerID,
|
||||||
|
"mode": "single-agent",
|
||||||
|
"error": "gemini session runner is not configured",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
|
||||||
|
if sessionID == "" {
|
||||||
|
return map[string]any{
|
||||||
|
"success": false,
|
||||||
|
"provider": s.providerID,
|
||||||
|
"mode": "single-agent",
|
||||||
|
"error": "sessionId is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state := s.getOrCreateSession(sessionID)
|
||||||
|
if method == "session.start" {
|
||||||
|
state = s.resetSession(sessionID)
|
||||||
|
}
|
||||||
|
taskPrompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
|
||||||
|
taskPrompt = shared.AugmentPromptWithAttachments(taskPrompt, params)
|
||||||
|
if taskPrompt == "" {
|
||||||
|
return map[string]any{
|
||||||
|
"success": false,
|
||||||
|
"provider": s.providerID,
|
||||||
|
"mode": "single-agent",
|
||||||
|
"error": "taskPrompt is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model := strings.TrimSpace(shared.StringArg(params, "model", ""))
|
||||||
|
if model == "" {
|
||||||
|
model = state.model
|
||||||
|
}
|
||||||
|
workingDirectory := strings.TrimSpace(shared.StringArg(params, "workingDirectory", ""))
|
||||||
|
if workingDirectory == "" {
|
||||||
|
workingDirectory = state.workingDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsHistory := append([]string(nil), state.history...)
|
||||||
|
sessionsHistory = append(sessionsHistory, taskPrompt)
|
||||||
|
composedPrompt := shared.ComposeHistoryPrompt(sessionsHistory)
|
||||||
|
output, err := s.sessionRunner(context.Background(), model, composedPrompt, workingDirectory)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{
|
||||||
|
"success": false,
|
||||||
|
"provider": s.providerID,
|
||||||
|
"mode": "single-agent",
|
||||||
|
"error": err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
state = s.sessions[sessionID]
|
||||||
|
if state == nil {
|
||||||
|
state = &adapterSession{}
|
||||||
|
s.sessions[sessionID] = state
|
||||||
|
}
|
||||||
|
state.history = sessionsHistory
|
||||||
|
state.model = model
|
||||||
|
state.workingDirectory = workingDirectory
|
||||||
|
state.lastOutput = output
|
||||||
|
state.lastUpstreamMethod = "prompt"
|
||||||
|
s.sessionsMu.Unlock()
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"provider": s.providerID,
|
||||||
|
"mode": "single-agent",
|
||||||
|
"output": output,
|
||||||
|
"sessionId": sessionID,
|
||||||
|
"upstreamMethod": "prompt",
|
||||||
|
}
|
||||||
|
if workingDirectory != "" {
|
||||||
|
result["effectiveWorkingDirectory"] = workingDirectory
|
||||||
|
}
|
||||||
|
if model != "" {
|
||||||
|
result["resolvedModel"] = model
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getOrCreateSession(sessionID string) *adapterSession {
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
defer s.sessionsMu.Unlock()
|
||||||
|
state := s.sessions[sessionID]
|
||||||
|
if state == nil {
|
||||||
|
state = &adapterSession{}
|
||||||
|
s.sessions[sessionID] = state
|
||||||
|
}
|
||||||
|
return &adapterSession{
|
||||||
|
history: append([]string(nil), state.history...),
|
||||||
|
model: state.model,
|
||||||
|
workingDirectory: state.workingDirectory,
|
||||||
|
lastOutput: state.lastOutput,
|
||||||
|
lastUpstreamMethod: state.lastUpstreamMethod,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resetSession(sessionID string) *adapterSession {
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
defer s.sessionsMu.Unlock()
|
||||||
|
state := &adapterSession{}
|
||||||
|
s.sessions[sessionID] = state
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) closeSession(sessionID string) bool {
|
||||||
|
sessionID = strings.TrimSpace(sessionID)
|
||||||
|
if sessionID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
defer s.sessionsMu.Unlock()
|
||||||
|
if _, ok := s.sessions[sessionID]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(s.sessions, sessionID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func parseAllowedOrigins(raw string) []string {
|
func parseAllowedOrigins(raw string) []string {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package geminiadapter
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -72,12 +73,14 @@ func TestHandleRPCSessionStartReturnsUpstreamResult(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
server := NewServer(stub)
|
server := NewServer(stub)
|
||||||
|
server.upstreamMethod = "session.start"
|
||||||
|
|
||||||
body, _ := json.Marshal(shared.RPCRequest{
|
body, _ := json.Marshal(shared.RPCRequest{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Method: "session.start",
|
Method: "session.start",
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
|
"sessionId": "s1",
|
||||||
"taskPrompt": "hello",
|
"taskPrompt": "hello",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -102,6 +105,114 @@ func TestHandleRPCSessionStartReturnsUpstreamResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleSessionStartFallsBackToPromptRunner(t *testing.T) {
|
||||||
|
stub := &stubClient{
|
||||||
|
initResult: initializeResult{ProtocolVersion: 1},
|
||||||
|
}
|
||||||
|
server := NewServer(stub)
|
||||||
|
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||||
|
if model != "gemini-2.5-pro" {
|
||||||
|
t.Fatalf("expected model gemini-2.5-pro, got %q", model)
|
||||||
|
}
|
||||||
|
if workingDirectory != "/tmp/demo" {
|
||||||
|
t.Fatalf("expected workingDirectory /tmp/demo, got %q", workingDirectory)
|
||||||
|
}
|
||||||
|
expectedPrompt := "## User Turn 1\nReply with exactly pong"
|
||||||
|
if prompt != expectedPrompt {
|
||||||
|
t.Fatalf("unexpected prompt %q", prompt)
|
||||||
|
}
|
||||||
|
return "pong", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := server.handleRequest(shared.RPCRequest{
|
||||||
|
Method: "session.start",
|
||||||
|
Params: map[string]any{
|
||||||
|
"sessionId": "s1",
|
||||||
|
"taskPrompt": "Reply with exactly pong",
|
||||||
|
"model": "gemini-2.5-pro",
|
||||||
|
"workingDirectory": "/tmp/demo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got := result["output"]; got != "pong" {
|
||||||
|
t.Fatalf("expected output pong, got %#v", result)
|
||||||
|
}
|
||||||
|
if got := result["upstreamMethod"]; got != "prompt" {
|
||||||
|
t.Fatalf("expected prompt upstream method, got %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSessionMessageReusesAdapterLocalHistory(t *testing.T) {
|
||||||
|
stub := &stubClient{
|
||||||
|
initResult: initializeResult{ProtocolVersion: 1},
|
||||||
|
}
|
||||||
|
server := NewServer(stub)
|
||||||
|
callCount := 0
|
||||||
|
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||||
|
callCount++
|
||||||
|
if callCount == 1 {
|
||||||
|
expected := "## User Turn 1\nFirst turn"
|
||||||
|
if prompt != expected {
|
||||||
|
t.Fatalf("unexpected first prompt %q", prompt)
|
||||||
|
}
|
||||||
|
return "first-reply", nil
|
||||||
|
}
|
||||||
|
expected := "## User Turn 1\nFirst turn\n\n## User Turn 2\nSecond turn"
|
||||||
|
if prompt != expected {
|
||||||
|
t.Fatalf("unexpected second prompt %q", prompt)
|
||||||
|
}
|
||||||
|
if workingDirectory != "/tmp/demo" {
|
||||||
|
t.Fatalf("expected inherited workingDirectory, got %q", workingDirectory)
|
||||||
|
}
|
||||||
|
if model != "gemini-2.5-flash" {
|
||||||
|
t.Fatalf("expected inherited model, got %q", model)
|
||||||
|
}
|
||||||
|
return "second-reply", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
server.handleRequest(shared.RPCRequest{
|
||||||
|
Method: "session.start",
|
||||||
|
Params: map[string]any{
|
||||||
|
"sessionId": "s1",
|
||||||
|
"taskPrompt": "First turn",
|
||||||
|
"model": "gemini-2.5-flash",
|
||||||
|
"workingDirectory": "/tmp/demo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
result := server.handleRequest(shared.RPCRequest{
|
||||||
|
Method: "session.message",
|
||||||
|
Params: map[string]any{
|
||||||
|
"sessionId": "s1",
|
||||||
|
"taskPrompt": "Second turn",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got := result["output"]; got != "second-reply" {
|
||||||
|
t.Fatalf("expected second reply, got %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionCloseDropsAdapterLocalState(t *testing.T) {
|
||||||
|
server := NewServer(&stubClient{initResult: initializeResult{ProtocolVersion: 1}})
|
||||||
|
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
server.handleRequest(shared.RPCRequest{
|
||||||
|
Method: "session.start",
|
||||||
|
Params: map[string]any{
|
||||||
|
"sessionId": "s1",
|
||||||
|
"taskPrompt": "hello",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
result := server.handleRequest(shared.RPCRequest{
|
||||||
|
Method: "session.close",
|
||||||
|
Params: map[string]any{
|
||||||
|
"sessionId": "s1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got := result["closed"]; got != true {
|
||||||
|
t.Fatalf("expected closed true, got %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleWebSocketCapabilities(t *testing.T) {
|
func TestHandleWebSocketCapabilities(t *testing.T) {
|
||||||
server := NewServer(&stubClient{
|
server := NewServer(&stubClient{
|
||||||
initResult: initializeResult{ProtocolVersion: 1},
|
initResult: initializeResult{ProtocolVersion: 1},
|
||||||
|
|||||||
@ -27,5 +27,11 @@ func (s *StaticTokenAuthService) ValidateAuthorizationHeader(header string) bool
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(header[len("Bearer "):]) != ""
|
return strings.TrimSpace(header[len("Bearer "):]) != ""
|
||||||
}
|
}
|
||||||
return header == s.expectedToken
|
if header == s.expectedToken {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(header[len("Bearer "):]) == s.expectedToken
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user