package serve
import (
"encoding/json"
"slices"
"strings"
"testing"
"github.com/johannSo/codey/internal/acp"
)
// The handshake T3 Code actually sends, byte for byte.
//
// These are not paraphrases of the protocol. They are the literal payloads
// built in T3 Code's ACP session runtime, and the assertions below are its
// schema's required fields. When a nightly of theirs breaks us, this is the
// test that says where — which is worth more than a test written against what
// the specification says ought to happen.
//
// Source: apps/server/src/provider/acp/AcpSessionRuntime.ts, at t3code
// 9accc567. initialize at :696-705 with capabilities from :566-578,
// authenticate at :710-718, session/new at :836-849, session/prompt at
// :1005-1014.
const (
t3Initialize = `{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"clientInfo":{"name":"t3-code","version":"0.0.0"}}`
t3Authenticate = `{"methodId":"cached_token"}`
)
// raw sends a method with params already encoded, so nothing between the test
// and the wire can quietly fix a payload up.
func (c *testClient) raw(method, params string) json.RawMessage {
c.t.Helper()
c.nextID++
id := json.RawMessage(itoa(c.nextID))
c.write(acpMessage{JSONRPC: "2.0", ID: &id, Method: method, Params: json.RawMessage(params)})
m := c.pump(string(id))
if m.Error != nil {
c.t.Fatalf("%s: %v", method, m.Error)
}
return m.Result
}
// acpMessage mirrors the wire frame so this file can write one by hand.
type acpMessage struct {
JSONRPC string `json:"jsonrpc"`
ID *json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
}
func field(t *testing.T, raw json.RawMessage, name string) any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("result is not an object: %s", raw)
}
v, ok := m[name]
if !ok {
t.Fatalf("result has no %q: %s", name, raw)
}
return v
}
func TestTheHandshakeT3CodeActuallySends(t *testing.T) {
f := newFakeAPI(t, msg("end_turn", textBlock("hello from codey")))
c := newTestServer(t, f, Options{})
// initialize — the client requires protocolVersion, a number.
init := c.raw("initialize", t3Initialize)
if v, ok := field(t, init, "protocolVersion").(float64); !ok || int(v) != 1 {
t.Fatalf("protocolVersion = %v, want 1", field(t, init, "protocolVersion"))
}
// authenticate — sent unconditionally with a method id we never offered,
// and its failure is not caught on the other end.
c.raw("authenticate", t3Authenticate)
// session/new — only sessionId is consumed, and it must be a string.
root := t.TempDir()
newSession := c.raw("session/new", `{"cwd":`+quote(root)+`,"mcpServers":[]}`)
id, ok := field(t, newSession, "sessionId").(string)
if !ok || id == "" {
t.Fatalf("sessionId = %v", field(t, newSession, "sessionId"))
}
// session/prompt — only stopReason is read, and it has to be one of five.
prompt := c.raw("session/prompt", `{"sessionId":`+quote(id)+`,"prompt":[{"type":"text","text":"say hello"}]}`)
stop, ok := field(t, prompt, "stopReason").(string)
if !ok {
t.Fatalf("stopReason = %v", field(t, prompt, "stopReason"))
}
valid := []string{"end_turn", "max_tokens", "max_turn_requests", "refusal", "cancelled"}
if !slices.Contains(valid, stop) {
t.Fatalf("stopReason %q is not one the client's schema allows", stop)
}
// session/cancel is a notification: no id, nothing to answer.
c.write(acpMessage{JSONRPC: "2.0", Method: "session/cancel", Params: json.RawMessage(`{"sessionId":` + quote(id) + `}`)})
}
func TestEveryNotificationDecodesAgainstTheClientSchema(t *testing.T) {
f := newFakeAPI(t,
msg("tool_use", toolUseBlock("t1", "write_file", `{"path":"note.txt","content":"hi"}`)),
msg("end_turn", textBlock("done")),
)
c := newTestServer(t, f, Options{Permission: ModeFullAccess})
id := c.handshake(t.TempDir())
c.prompt(id, "write a note")
updates := c.sessionUpdates(id)
if len(updates) == 0 {
t.Fatal("the turn produced no updates at all")
}
// The variants the client switches on. Anything else it drops silently,
// which is a worse failure than a loud one: the row simply never appears.
known := []string{
"agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update",
"plan", "current_mode_update", "config_option_update", "available_commands_update",
}
var sawToolCall, sawToolUpdate bool
for _, u := range updates {
if !slices.Contains(known, u.SessionUpdate) {
t.Errorf("sessionUpdate %q is not one the client handles", u.SessionUpdate)
}
switch u.SessionUpdate {
case "agent_message_chunk", "agent_thought_chunk":
// The client reads content.type == "text" and content.text.
raw, _ := json.Marshal(u.Content)
var content struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &content); err != nil || content.Type != "text" {
t.Errorf("chunk content is not a text block: %s", raw)
}
case "tool_call":
sawToolCall = true
// title and toolCallId are the two required fields.
if u.ToolCallID == "" || u.Title == "" {
t.Errorf("tool_call is missing a required field: %+v", u)
}
if !slices.Contains([]string{"read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other"}, u.Kind) {
t.Errorf("tool kind %q is not in the client's vocabulary", u.Kind)
}
if !slices.Contains([]string{"pending", "in_progress", "completed", "failed"}, u.Status) {
t.Errorf("tool status %q is not in the client's vocabulary", u.Status)
}
case "tool_call_update":
sawToolUpdate = true
if u.ToolCallID == "" {
t.Error("tool_call_update with no toolCallId cannot be matched to its call")
}
}
}
if !sawToolCall || !sawToolUpdate {
t.Errorf("a turn that ran a tool produced no tool rows: %+v", updates)
}
}
func TestAPermissionRequestMatchesTheClientSchema(t *testing.T) {
f := newFakeAPI(t,
msg("tool_use", toolUseBlock("t1", "write_file", `{"path":"note.txt","content":"hi"}`)),
msg("end_turn", textBlock("done")),
)
c := newTestServer(t, f, Options{Permission: ModeAsk})
c.onPermission = func(acp.RequestPermissionParams) acp.RequestPermissionResult {
return acp.RequestPermissionResult{
Outcome: acp.PermissionOutcome{Outcome: acp.OutcomeSelected, OptionID: acp.OptAllowOnce},
}
}
id := c.handshake(t.TempDir())
c.prompt(id, "write a note")
c.mu.Lock()
seen := c.permission
c.mu.Unlock()
if seen == nil {
t.Fatal("no permission request was sent")
}
var params struct {
SessionID string `json:"sessionId"`
ToolCall struct {
ToolCallID string `json:"toolCallId"`
} `json:"toolCall"`
Options []struct {
OptionID string `json:"optionId"`
Name string `json:"name"`
Kind string `json:"kind"`
} `json:"options"`
}
if err := json.Unmarshal(seen, ¶ms); err != nil {
t.Fatalf("the request does not decode: %v\n%s", err, seen)
}
if params.SessionID != id || params.ToolCall.ToolCallID == "" {
t.Errorf("params = %s", seen)
}
if len(params.Options) == 0 {
t.Fatal("a permission request with no options cannot be answered")
}
// The option kinds are a closed set, and the ids have to include the ones
// a client may reply with regardless of what we offered.
var ids []string
for _, o := range params.Options {
if o.Name == "" {
t.Errorf("an option with no name cannot be shown: %+v", o)
}
if !slices.Contains([]string{"allow_once", "allow_always", "reject_once", "reject_always"}, o.Kind) {
t.Errorf("option kind %q is not in the client's vocabulary", o.Kind)
}
ids = append(ids, o.OptionID)
}
for _, want := range []string{"allow-once", "allow-always", "reject-once"} {
if !slices.Contains(ids, want) {
t.Errorf("option id %q is missing; a client that replies with it would not be understood. got %v", want, ids)
}
}
}
// quote is strconv.Quote for the handful of JSON strings built by hand here.
func quote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// stray is a guard: nothing but protocol frames may reach the stream.
func TestNothingButFramesReachTheStream(t *testing.T) {
f := newFakeAPI(t, msg("end_turn", textBlock("quiet")))
c := newTestServer(t, f, Options{})
id := c.handshake(t.TempDir())
c.prompt(id, "say something")
for _, line := range c.lines() {
if strings.TrimSpace(line) == "" {
continue
}
var probe map[string]any
if err := json.Unmarshal([]byte(line), &probe); err != nil {
t.Fatalf("a non-JSON line reached the stream and would desynchronise the parser: %q", line)
}
if probe["jsonrpc"] != "2.0" {
t.Errorf("a frame without jsonrpc 2.0: %q", line)
}
}
}
func TestTheProbeCanReadTheModelsFromTheHandshakeAlone(t *testing.T) {
// A health check spawns the agent, shakes hands and stops. T3 Code reads
// initialize._meta.modelState on exactly that path, so a model list only
// offered at session/new never reaches the provider card.
c := newTestServer(t, newFakeAPI(t), Options{
Models: []acp.ModelInfo{{ModelID: "codey-opus-5", Name: "Codey Opus 5"}},
DefaultModel: "codey-opus-5",
})
init := c.raw("initialize", t3Initialize)
var resp struct {
Meta struct {
ModelState struct {
CurrentModelID string `json:"currentModelId"`
AvailableModels []struct {
ModelID string `json:"modelId"`
Name string `json:"name"`
} `json:"availableModels"`
} `json:"modelState"`
} `json:"_meta"`
}
if err := json.Unmarshal(init, &resp); err != nil {
t.Fatalf("initialize result: %v", err)
}
state := resp.Meta.ModelState
if state.CurrentModelID != "codey-opus-5" || len(state.AvailableModels) != 1 {
t.Fatalf("_meta.modelState = %+v, from %s", state, init)
}
if state.AvailableModels[0].Name != "Codey Opus 5" {
t.Errorf("model name = %q", state.AvailableModels[0].Name)
}
}