v0.3.83 d66bd89

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

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