v0.3.95 beceda1

github_copilot_wire_test.go RawHistoryFolder

1package provider
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"net/http"
8	"net/http/httptest"
9	"strings"
10	"testing"
11	"time"
12)
13
14func seedCopilotTestToken(t *testing.T, baseURL string) string {
15	t.Helper()
16	pat := "synthetic-" + t.Name()
17	copilotCache.mu.Lock()
18	copilotCache.tokens[pat] = copilotToken{value: "synthetic-session-token", baseURL: baseURL, expiresAt: time.Now().Add(time.Hour)}
19	copilotCache.mu.Unlock()
20	t.Cleanup(func() {
21		copilotCache.mu.Lock()
22		delete(copilotCache.tokens, pat)
23		copilotCache.mu.Unlock()
24	})
25	return pat
26}
27
28func TestCopilotWireProtocols(t *testing.T) {
29	for _, tc := range []struct{ model, path string }{
30		{"claude-sonnet-5", "/v1/messages"},
31		{"claude-opus-5.5", "/v1/messages"},
32		{"claude-haiku-4.5", "/v1/messages"},
33		{"gpt-5.4", "/responses"},
34		{"grok-4.6", "/responses"},
35		{"mai-code-1.1-flash", "/responses"},
36		{"gemini-3.8-flash", "/chat/completions"},
37		{"kimi-k3", "/chat/completions"},
38	} {
39		t.Run(tc.model, func(t *testing.T) {
40			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41				if r.URL.Path != tc.path {
42					t.Errorf("path = %q, want %q", r.URL.Path, tc.path)
43				}
44				if r.Header.Get("Authorization") != "Bearer synthetic-session-token" {
45					t.Error("missing session Bearer auth")
46				}
47				if r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-Initiator") != "user" || r.Header.Get("Openai-Intent") != "conversation-edits" {
48					t.Error("missing Copilot identity headers")
49				}
50				for _, header := range []string{"x-api-key", "anthropic-beta", "x-app", "chatgpt-account-id", "openai-beta", "originator"} {
51					if r.Header.Get(header) != "" {
52						t.Errorf("unexpected identity header %s", header)
53					}
54				}
55				var body map[string]json.RawMessage
56				if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
57					t.Error(err)
58					w.WriteHeader(400)
59					return
60				}
61				if string(body["model"]) != fmt.Sprintf("%q", tc.model) {
62					t.Error("model ID changed on wire")
63				}
64				w.Header().Set("Content-Type", "text/event-stream")
65				switch tc.path {
66				case "/v1/messages":
67					if len(body["messages"]) == 0 || len(body["max_tokens"]) == 0 {
68						t.Error("missing Messages fields")
69					}
70					if strings.Contains(string(body["system"]), "Claude Code") {
71						t.Error("injected Claude Code system identity")
72					}
73					if tc.model == "claude-sonnet-5" || tc.model == "claude-opus-5.5" {
74						if string(body["thinking"]) != `{"type":"adaptive"}` {
75							t.Errorf("thinking = %s", body["thinking"])
76						}
77						if len(body["output_config"]) == 0 || len(body["temperature"]) != 0 {
78							t.Error("invalid adaptive thinking payload")
79						}
80					}
81					fmt.Fprint(w, "event: message_start\ndata: {\"message\":{\"id\":\"test\",\"usage\":{\"input_tokens\":1}}}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {}\n\n")
82				case "/responses":
83					if len(body["input"]) == 0 || len(body["reasoning"]) == 0 {
84						t.Error("missing Responses fields")
85					}
86					fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
87				default:
88					if len(body["reasoning_effort"]) != 0 {
89						t.Error("unsupported reasoning_effort sent")
90					}
91					if len(body["messages"]) == 0 {
92						t.Error("missing chat messages")
93					}
94					fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
95				}
96			}))
97			defer srv.Close()
98			client := NewGithubCopilotClient(seedCopilotTestToken(t, srv.URL))
99			stream, err := client.Stream(context.Background(), Request{Model: tc.model, Reasoning: "high", System: "Test system", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "Hello"}}}}})
100			if err != nil {
101				t.Fatal(err)
102			}
103			for event := range stream {
104				if e, ok := event.(EventDone); ok && e.Err != nil {
105					t.Fatalf("stream error: %v", e.Err)
106				}
107				if e, ok := event.(EventStart); ok && e.Provider != "github-copilot" {
108					t.Errorf("event provider = %s", e.Provider)
109				}
110			}
111		})
112	}
113}
114
115type copilotMetadataCapture struct {
116	metadata  copilotRequestMetadata
117	reasoning string
118}
119
120func (c *copilotMetadataCapture) Name() string { return "github-copilot" }
121func (c *copilotMetadataCapture) Stream(ctx context.Context, req Request) (<-chan Event, error) {
122	c.metadata = ctx.Value(copilotRequestKey{}).(copilotRequestMetadata)
123	c.reasoning = req.Reasoning
124	ch := make(chan Event)
125	close(ch)
126	return ch, nil
127}
128
129func TestCopilotDynamicRequestMetadata(t *testing.T) {
130	client := NewGithubCopilotClient("unused").(*copilotClient)
131	capture := &copilotMetadataCapture{}
132	client.router.byAPI[APICompletions] = capture
133	for _, tc := range []struct {
134		messages  []Message
135		initiator string
136		vision    bool
137	}{
138		{nil, "user", false},
139		{[]Message{{Role: RoleUser, Content: []Content{ImageBlock{}}}}, "user", true},
140		{[]Message{{Role: RoleTool, Content: []Content{ToolResultBlock{Content: []Content{ImageBlock{}}}}}}, "agent", true},
141		{[]Message{{Role: RoleAssistant}}, "agent", false},
142		{[]Message{{Role: RoleUser}}, "user", false},
143	} {
144		_, err := client.Stream(context.Background(), Request{Model: "kimi-k3", Reasoning: "high", Messages: tc.messages})
145		if err != nil {
146			t.Fatal(err)
147		}
148		if capture.metadata.initiator != tc.initiator || capture.metadata.vision != tc.vision || capture.reasoning != "" {
149			t.Errorf("unexpected metadata: %+v, reasoning %q", capture.metadata, capture.reasoning)
150		}
151	}
152	// The transport must propagate vision metadata and remove it on the next text-only request.
153	mock := &mockRoundTripper{}
154	transport := &copilotRefreshTransport{inner: mock, pat: seedCopilotTestToken(t, copilotDefaultBaseURL)}
155	for _, vision := range []bool{true, false} {
156		ctx := context.WithValue(context.Background(), copilotRequestKey{}, copilotRequestMetadata{initiator: "agent", vision: vision})
157		req, _ := http.NewRequestWithContext(ctx, "POST", copilotDefaultBaseURL+"/responses", nil)
158		req.Header.Set("Copilot-Vision-Request", "stale")
159		resp, err := transport.RoundTrip(req)
160		if err != nil {
161			t.Fatal(err)
162		}
163		resp.Body.Close()
164		want := ""
165		if vision {
166			want = "true"
167		}
168		if mock.lastReq.Header.Get("Copilot-Vision-Request") != want || mock.lastReq.Header.Get("X-Initiator") != "agent" {
169			t.Error("incorrect dynamic transport headers")
170		}
171		if req.Header.Get("Copilot-Vision-Request") != "stale" {
172			t.Error("transport mutated caller headers")
173		}
174	}
175}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close