From 0d7da442f730a009a90cdf9c39f3c025490b7896 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Wed, 24 Jun 2026 11:45:47 +0200 Subject: [PATCH 1/2] Add GitHub Copilot agent provider Add a GitHub Copilot-backed agent provider using the official Copilot SDK, including session creation and resume, streaming and non-streaming event projection, tool declarations and results, data attachments, and session error handling. Add RawContent support with jsonx fallback unmarshalling for unknown or missing content types, plus a GitHub Copilot sample and blackbox provider tests. --- agent/provider/copilotagent/copilot.go | 623 +++++++++++++ agent/provider/copilotagent/copilot_test.go | 816 ++++++++++++++++++ .../providers/github-copilot/main.go | 66 ++ go.mod | 2 + go.sum | 6 + internal/jsonx/jsonx.go | 43 +- internal/jsonx/jsonx_test.go | 72 ++ message/content.go | 46 +- message/content_test.go | 55 ++ 9 files changed, 1715 insertions(+), 14 deletions(-) create mode 100644 agent/provider/copilotagent/copilot.go create mode 100644 agent/provider/copilotagent/copilot_test.go create mode 100644 examples/02-agents/providers/github-copilot/main.go create mode 100644 internal/jsonx/jsonx_test.go diff --git a/agent/provider/copilotagent/copilot.go b/agent/provider/copilotagent/copilot.go new file mode 100644 index 00000000..bc2a22eb --- /dev/null +++ b/agent/provider/copilotagent/copilot.go @@ -0,0 +1,623 @@ +// Copyright (c) Microsoft. All rights reserved. + +package copilotagent + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "iter" + "os" + "path/filepath" + "slices" + "strings" + + copilot "github.com/github/copilot-sdk/go" + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/tool" +) + +const ( + defaultName = "GitHub Copilot Agent" + defaultDescription = "An AI agent powered by GitHub Copilot" +) + +// Config contains configuration for a GitHub Copilot-backed [agent.Agent]. +type Config struct { + agent.Config + + // SessionConfig configures Copilot sessions created or resumed by the agent. + SessionConfig *copilot.SessionConfig + + // Instructions are appended to the Copilot system message for each run. + Instructions string +} + +type provider struct { + client *copilot.Client + cfg Config +} + +// New creates an agent backed by the GitHub Copilot SDK. +func New(cclient *copilot.Client, config Config) *agent.Agent { + if cclient == nil { + panic("copilotagent: client cannot be nil") + } + if config.Name == "" { + config.Name = defaultName + } + if config.Description == "" { + config.Description = defaultDescription + } + if config.Instructions != "" { + config.RunOptions = append(config.RunOptions, agent.WithInstructions(config.Instructions)) + } + p := &provider{ + client: cclient, + cfg: config, + } + return agent.New(agent.ProviderConfig{ + ProviderName: "copilot", + Run: p.run, + }, config.Config) +} + +func (p *provider) run(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + if p.client == nil { + yield(nil, errors.New("copilotagent: client cannot be nil")) + return + } + + if err := p.client.Start(ctx); err != nil { + yield(nil, err) + return + } + + events := make(chan copilot.SessionEvent, 128) + eventHandler := func(event copilot.SessionEvent) { + select { + case events <- event: + case <-ctx.Done(): + } + } + + frameworkSession, _ := agent.GetOption(options, agent.WithSession) + isStreaming := p.streaming(options) + copilotSession, err := p.openSession(ctx, frameworkSession, isStreaming, options) + if err != nil { + yield(nil, err) + return + } + defer func() { _ = copilotSession.Disconnect() }() + unsubscribe := copilotSession.On(eventHandler) + defer unsubscribe() + + if frameworkSession != nil && frameworkSession.ServiceID() == "" { + frameworkSession.SetServiceID(copilotSession.SessionID) + } + + messageOptions, cleanupAttachments, err := buildMessageOptions(messages) + if err != nil { + yield(nil, err) + return + } + defer cleanupAttachments() + + if _, err := copilotSession.Send(ctx, messageOptions); err != nil { + yield(nil, err) + return + } + + for { + select { + case event := <-events: + var update *agent.ResponseUpdate + var done bool + var eventErr error + switch data := event.Data.(type) { + case *copilot.AssistantMessageDeltaData: + update = p.assistantMessageDeltaUpdate(event, data) + case *copilot.AssistantMessageData: + update = p.assistantMessageUpdate(event, data, isStreaming) + case *copilot.ToolExecutionStartData: + update = p.toolExecutionStartUpdate(event, data) + case *copilot.ToolExecutionCompleteData: + update = p.toolExecutionCompleteUpdate(event, data) + case *copilot.AssistantUsageData: + update = p.assistantUsageUpdate(event, data) + case *copilot.SessionIdleData: + update = rawEventUpdate(event) + done = true + case *copilot.SessionErrorData: + update = rawEventUpdate(event) + done = true + eventErr = fmt.Errorf("session error: %s", sessionErrorMessage(data)) + default: + update = rawEventUpdate(event) + } + if update != nil { + if !yield(update, nil) { + return + } + } + if eventErr != nil { + yield(nil, eventErr) + return + } + if done { + return + } + case <-ctx.Done(): + yield(nil, ctx.Err()) + return + } + } + } +} + +func (p *provider) streaming(options []agent.Option) bool { + streaming := true + if p.cfg.SessionConfig != nil && p.cfg.SessionConfig.Streaming != nil { + streaming = *p.cfg.SessionConfig.Streaming + } + if stream, ok := agent.GetOption(options, agent.Stream); ok { + streaming = stream + } + return streaming +} + +func (p *provider) openSession( + ctx context.Context, + frameworkSession *agent.Session, + streaming bool, + options []agent.Option, +) (*copilot.Session, error) { + if frameworkSession != nil && frameworkSession.ServiceID() != "" { + cfg := p.resumeSessionConfig(streaming, options) + return p.client.ResumeSession(ctx, frameworkSession.ServiceID(), &cfg) + } + cfg := p.sessionConfig(streaming, options) + return p.client.CreateSession(ctx, &cfg) +} + +func (p *provider) sessionConfig(streaming bool, options []agent.Option) copilot.SessionConfig { + cfg := copySessionConfig(p.cfg.SessionConfig) + cfg.Streaming = copilot.Bool(streaming) + cfg.SystemMessage = systemMessageWithInstructions(cfg.SystemMessage, slices.Collect(agent.AllOptions(options, agent.WithInstructions))) + cfg.Tools = append(cfg.Tools, copilotTools(options)...) + return cfg +} + +func (p *provider) resumeSessionConfig(streaming bool, options []agent.Option) copilot.ResumeSessionConfig { + cfg := copyResumeSessionConfig(p.cfg.SessionConfig) + cfg.Streaming = copilot.Bool(streaming) + cfg.SystemMessage = systemMessageWithInstructions(cfg.SystemMessage, slices.Collect(agent.AllOptions(options, agent.WithInstructions))) + cfg.Tools = append(cfg.Tools, copilotTools(options)...) + return cfg +} + +func copySessionConfig(source *copilot.SessionConfig) copilot.SessionConfig { + if source == nil { + return copilot.SessionConfig{Streaming: copilot.Bool(true)} + } + copy := *source + copy.Streaming = copyBoolDefaultTrue(source.Streaming) + return copy +} + +func copyResumeSessionConfig(source *copilot.SessionConfig) copilot.ResumeSessionConfig { + if source == nil { + return copilot.ResumeSessionConfig{Streaming: copilot.Bool(true)} + } + return copilot.ResumeSessionConfig{ + Model: source.Model, + ReasoningEffort: source.ReasoningEffort, + Tools: source.Tools, + SystemMessage: source.SystemMessage, + AvailableTools: source.AvailableTools, + ExcludedTools: source.ExcludedTools, + Provider: source.Provider, + OnPermissionRequest: source.OnPermissionRequest, + OnUserInputRequest: source.OnUserInputRequest, + Hooks: source.Hooks, + WorkingDirectory: source.WorkingDirectory, + ConfigDirectory: source.ConfigDirectory, + MCPServers: source.MCPServers, + CustomAgents: source.CustomAgents, + SkillDirectories: source.SkillDirectories, + DisabledSkills: source.DisabledSkills, + InfiniteSessions: source.InfiniteSessions, + Streaming: copyBoolDefaultTrue(source.Streaming), + } +} + +func copyBoolDefaultTrue(source *bool) *bool { + if source == nil { + return copilot.Bool(true) + } + value := *source + return &value +} + +func systemMessageWithInstructions(base *copilot.SystemMessageConfig, instructions []string) *copilot.SystemMessageConfig { + instructions = compactInstructions(instructions) + if len(instructions) == 0 { + return base + } + joined := strings.Join(instructions, "\n") + if base == nil { + return &copilot.SystemMessageConfig{ + Mode: "append", + Content: joined, + } + } + copy := *base + if copy.Content == "" { + copy.Content = joined + } else { + copy.Content += "\n" + joined + } + return © +} + +func compactInstructions(instructions []string) []string { + return slices.DeleteFunc(slices.Clone(instructions), func(instruction string) bool { + return strings.TrimSpace(instruction) == "" + }) +} + +func copilotTools(options []agent.Option) []copilot.Tool { + var out []copilot.Tool + for tl := range agent.AllOptions(options, agent.WithTool) { + funcTool, ok := tl.(tool.FuncTool) + if !ok { + continue + } + converted, err := toCopilotTool(funcTool) + if err != nil { + converted = copilot.Tool{ + Name: funcTool.Name(), + Description: funcTool.Description(), + } + } + out = append(out, converted) + } + return out +} + +func toCopilotTool(funcTool tool.FuncTool) (copilot.Tool, error) { + parameters, err := schemaMap(funcTool.Schema()) + if err != nil { + return copilot.Tool{}, err + } + converted := copilot.Tool{ + Name: funcTool.Name(), + Description: funcTool.Description(), + Parameters: parameters, + SkipPermission: !approvalRequired(funcTool), + Handler: func(invocation copilot.ToolInvocation) (copilot.ToolResult, error) { + arguments, err := toolArguments(invocation.Arguments) + if err != nil { + return copilot.ToolResult{}, err + } + ctx := invocation.TraceContext + if ctx == nil { + ctx = context.Background() + } + result, err := funcTool.Call(ctx, arguments) + if err != nil { + return copilot.ToolResult{}, err + } + return toolResult(result) + }, + } + return converted, nil +} + +func schemaMap(schema any) (map[string]any, error) { + if schema == nil { + return nil, nil + } + if schemaMap, ok := schema.(map[string]any); ok { + return schemaMap, nil + } + data, err := json.Marshal(schema) + if err != nil { + return nil, fmt.Errorf("failed to marshal tool schema of type %T: %w", schema, err) + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("failed to unmarshal tool schema as JSON object: %w", err) + } + return out, nil +} + +func approvalRequired(t tool.Tool) bool { + approvalTool, ok := t.(tool.ApprovalRequiredTool) + return ok && approvalTool.ApprovalRequired() +} + +func toolArguments(arguments any) (string, error) { + if arguments == nil { + return "{}", nil + } + if raw, ok := arguments.(json.RawMessage); ok { + if len(raw) == 0 || string(raw) == "null" { + return "{}", nil + } + return string(raw), nil + } + data, err := json.Marshal(arguments) + if err != nil { + return "", err + } + if string(data) == "null" { + return "{}", nil + } + return string(data), nil +} + +func toolResult(result any) (copilot.ToolResult, error) { + if result == nil { + return copilot.ToolResult{ResultType: "success"}, nil + } + if copilotResult, ok := result.(copilot.ToolResult); ok { + return copilotResult, nil + } + if text, ok := result.(string); ok { + return copilot.ToolResult{ + TextResultForLLM: text, + ResultType: "success", + }, nil + } + if raw, ok := result.(json.RawMessage); ok { + return copilot.ToolResult{ + TextResultForLLM: string(raw), + ResultType: "success", + }, nil + } + data, err := json.Marshal(result) + if err != nil { + return copilot.ToolResult{}, err + } + return copilot.ToolResult{ + TextResultForLLM: string(data), + ResultType: "success", + }, nil +} + +func buildMessageOptions(messages []*message.Message) (copilot.MessageOptions, func(), error) { + var promptParts []string + var attachments []copilot.Attachment + var tempDir string + cleanup := func() { + if tempDir != "" { + _ = os.RemoveAll(tempDir) + } + } + for _, msg := range messages { + if msg == nil { + continue + } + promptParts = append(promptParts, msg.String()) + for _, content := range msg.Contents { + dataContent, ok := content.(*message.DataContent) + if !ok { + continue + } + if tempDir == "" { + var err error + tempDir, err = os.MkdirTemp("", "af_copilot_*") + if err != nil { + cleanup() + return copilot.MessageOptions{}, func() {}, err + } + } + path, displayName, err := saveDataContentAttachment(tempDir, len(attachments), dataContent) + if err != nil { + cleanup() + return copilot.MessageOptions{}, func() {}, err + } + attachments = append(attachments, &copilot.AttachmentFile{ + Path: path, + DisplayName: displayName, + }) + } + } + return copilot.MessageOptions{ + Prompt: strings.Join(promptParts, "\n"), + Attachments: attachments, + }, cleanup, nil +} + +func saveDataContentAttachment(tempDir string, index int, content *message.DataContent) (string, string, error) { + data, err := base64.StdEncoding.DecodeString(content.Data) + if err != nil { + return "", "", err + } + displayName := filepath.Base(content.Name) + if displayName == "." || displayName == string(filepath.Separator) || displayName == "" { + displayName = fmt.Sprintf("attachment-%d", index+1) + } + path := filepath.Join(tempDir, displayName) + if err := os.WriteFile(path, data, 0600); err != nil { + return "", "", err + } + return path, displayName, nil +} + +func (p *provider) assistantMessageDeltaUpdate(event copilot.SessionEvent, data *copilot.AssistantMessageDeltaData) *agent.ResponseUpdate { + content := &message.TextContent{ + ContentHeader: message.ContentHeader{RawRepresentation: event}, + Text: data.DeltaContent, + } + return &agent.ResponseUpdate{ + RawRepresentation: event, + Role: message.RoleAssistant, + MessageID: data.MessageID, + CreatedAt: event.Timestamp, + Contents: []message.Content{content}, + } +} + +func (p *provider) assistantMessageUpdate(event copilot.SessionEvent, data *copilot.AssistantMessageData, streaming bool) *agent.ResponseUpdate { + update := &agent.ResponseUpdate{ + RawRepresentation: event, + Role: message.RoleAssistant, + ResponseID: data.MessageID, + MessageID: data.MessageID, + CreatedAt: event.Timestamp, + } + if streaming { + update.Contents = []message.Content{&message.RawContent{ + ContentHeader: message.ContentHeader{RawRepresentation: event}, + }} + } else { + update.Contents = []message.Content{&message.TextContent{ + ContentHeader: message.ContentHeader{RawRepresentation: event}, + Text: data.Content, + }} + } + return update +} + +func (p *provider) toolExecutionStartUpdate(event copilot.SessionEvent, data *copilot.ToolExecutionStartData) *agent.ResponseUpdate { + arguments, argErr := functionArguments(data.Arguments) + content := &message.FunctionCallContent{ + ContentHeader: message.ContentHeader{RawRepresentation: event}, + CallID: data.ToolCallID, + Name: data.ToolName, + Arguments: arguments, + Error: argErr, + } + return &agent.ResponseUpdate{ + RawRepresentation: event, + Role: message.RoleAssistant, + CreatedAt: event.Timestamp, + Contents: []message.Content{content}, + } +} + +func (p *provider) toolExecutionCompleteUpdate(event copilot.SessionEvent, data *copilot.ToolExecutionCompleteData) *agent.ResponseUpdate { + content := &message.FunctionResultContent{ + ContentHeader: message.ContentHeader{RawRepresentation: event}, + CallID: data.ToolCallID, + Result: toolExecutionResult(data), + } + return &agent.ResponseUpdate{ + RawRepresentation: event, + Role: message.RoleTool, + CreatedAt: event.Timestamp, + Contents: []message.Content{content}, + } +} + +func toolExecutionResult(data *copilot.ToolExecutionCompleteData) any { + if data == nil { + return "Tool execution failed" + } + if data.Success { + if data.Result == nil { + return nil + } + return data.Result.Content + } + if data.Error != nil && data.Error.Message != "" { + return data.Error.Message + } + return "Tool execution failed" +} + +func (p *provider) assistantUsageUpdate(event copilot.SessionEvent, data *copilot.AssistantUsageData) *agent.ResponseUpdate { + inputTokens := int64Value(data.InputTokens) + outputTokens := int64Value(data.OutputTokens) + details := message.UsageDetails{ + InputTokenCount: inputTokens, + OutputTokenCount: outputTokens, + TotalTokenCount: inputTokens + outputTokens, + CachedInputTokenCount: int64Value(data.CacheReadTokens), + AdditionalCounts: additionalUsageCounts(data), + } + update := &agent.ResponseUpdate{ + RawRepresentation: event, + Role: message.RoleAssistant, + CreatedAt: event.Timestamp, + Contents: []message.Content{&message.UsageContent{ + ContentHeader: message.ContentHeader{RawRepresentation: event}, + Details: details, + }}, + } + if data.FinishReason != nil { + update.FinishReason = *data.FinishReason + } + return update +} + +func additionalUsageCounts(data *copilot.AssistantUsageData) map[string]int64 { + additional := make(map[string]int64) + if data.CacheWriteTokens != nil { + additional["CacheWriteTokens"] = *data.CacheWriteTokens + } + if data.Cost != nil { + additional["Cost"] = int64(*data.Cost) + } + if data.Duration != nil { + additional["Duration"] = *data.Duration + } + if len(additional) == 0 { + return nil + } + return additional +} + +func int64Value(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +func rawEventUpdate(event copilot.SessionEvent) *agent.ResponseUpdate { + return &agent.ResponseUpdate{ + RawRepresentation: event, + Role: message.RoleAssistant, + CreatedAt: event.Timestamp, + Contents: []message.Content{&message.RawContent{ + ContentHeader: message.ContentHeader{RawRepresentation: event}, + }}, + } +} + +func sessionErrorMessage(data *copilot.SessionErrorData) string { + if data != nil && data.Message != "" { + return data.Message + } + return "unknown error" +} + +func functionArguments(arguments any) (string, error) { + if arguments == nil { + return "", nil + } + data, err := json.Marshal(arguments) + if err != nil { + return "", err + } + if isJSONObject(data) { + return string(data), nil + } + wrapped, err := json.Marshal(map[string]any{"value": arguments}) + if err != nil { + return "", err + } + return string(wrapped), nil +} + +func isJSONObject(data []byte) bool { + data = []byte(strings.TrimSpace(string(data))) + return len(data) > 0 && data[0] == '{' +} diff --git a/agent/provider/copilotagent/copilot_test.go b/agent/provider/copilotagent/copilot_test.go new file mode 100644 index 00000000..18cf4f34 --- /dev/null +++ b/agent/provider/copilotagent/copilot_test.go @@ -0,0 +1,816 @@ +// Copyright (c) Microsoft. All rights reserved. + +package copilotagent_test + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/rpc" + agentpkg "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/provider/copilotagent" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/tool" + "github.com/microsoft/agent-framework-go/tool/functool" +) + +func TestConstructor_WithCopilotClient_InitializesPropertiesCorrectly(t *testing.T) { + agent := copilotagent.New(copilot.NewClient(nil), copilotagent.Config{Config: agentpkg.Config{ + ID: "test-id", + Name: "test-name", + Description: "test-description", + }}) + + if got := agent.ID(); got != "test-id" { + t.Fatalf("ID = %q, want test-id", got) + } + if got := agent.Name(); got != "test-name" { + t.Fatalf("Name = %q, want test-name", got) + } + if got := agent.Description(); got != "test-description" { + t.Fatalf("Description = %q, want test-description", got) + } +} + +func TestConstructor_WithNullCopilotClient_Panics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("New did not panic") + } + }() + copilotagent.New(nil, copilotagent.Config{}) +} + +func TestConstructor_WithDefaultParameters_UsesBaseProperties(t *testing.T) { + agent := copilotagent.New(copilot.NewClient(nil), copilotagent.Config{}) + + if agent.ID() == "" { + t.Fatal("ID is empty") + } + if got := agent.Name(); got != "GitHub Copilot Agent" { + t.Fatalf("Name = %q, want GitHub Copilot Agent", got) + } + if got := agent.Description(); got != "An AI agent powered by GitHub Copilot" { + t.Fatalf("Description = %q, want default description", got) + } +} + +func TestCreateSession_ReturnsSession(t *testing.T) { + agent := copilotagent.New(copilot.NewClient(nil), copilotagent.Config{}) + + session, err := agent.CreateSession(context.Background()) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if session == nil { + t.Fatal("session is nil") + } +} + +func TestCreateSession_WithSessionID_ReturnsSessionWithSessionID(t *testing.T) { + agent := copilotagent.New(copilot.NewClient(nil), copilotagent.Config{}) + + session, err := agent.CreateSession(context.Background(), agentpkg.WithServiceID("test-session-id")) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if got := session.ServiceID(); got != "test-session-id" { + t.Fatalf("ServiceID = %q, want test-session-id", got) + } +} + +func TestConstructor_WithTools_InitializesCorrectly(t *testing.T) { + testTool := functool.MustNew(functool.Config{Name: "TestFunc", Description: "Test function"}, func(context.Context, struct{}) (string, error) { + return "test", nil + }) + agent := copilotagent.New(copilot.NewClient(nil), copilotagent.Config{Config: agentpkg.Config{Tools: []tool.Tool{testTool}}}) + + if agent == nil { + t.Fatal("agent is nil") + } + if agent.ID() == "" { + t.Fatal("ID is empty") + } +} + +func TestCopySessionConfig_CopiesAllProperties(t *testing.T) { + runtime := newFakeRuntime(t, idleEvent()) + agent := copilotagent.New(runtime.client(), copilotagent.Config{SessionConfig: richSessionConfig()}) + + _, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + request := runtime.lastCreateRequest(t) + + assertEqual(t, request["model"], "gpt-4o", "model") + assertEqual(t, request["reasoningEffort"], "high", "reasoningEffort") + assertSystemMessage(t, request, "Be helpful") + assertStringSlice(t, request["availableTools"], []string{"tool1", "tool2"}, "availableTools") + assertStringSlice(t, request["excludedTools"], []string{"tool3"}, "excludedTools") + assertEqual(t, request["workingDirectory"], "/workspace", "workingDirectory") + assertEqual(t, request["configDir"], "/config", "configDir") + assertEqual(t, request["requestPermission"], true, "requestPermission") + assertEqual(t, request["requestUserInput"], true, "requestUserInput") + assertEqual(t, request["hooks"], true, "hooks") + if request["mcpServers"] == nil { + t.Fatal("mcpServers was not sent") + } + assertStringSlice(t, request["disabledSkills"], []string{"skill1"}, "disabledSkills") + assertEqual(t, request["streaming"], true, "streaming") +} + +func TestCopyResumeSessionConfig_CopiesAllProperties(t *testing.T) { + runtime := newFakeRuntime(t, idleEvent()) + agent := copilotagent.New(runtime.client(), copilotagent.Config{SessionConfig: richSessionConfig()}) + session, err := agent.CreateSession(context.Background(), agentpkg.WithServiceID("existing-session")) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + _, err = runText(t, agent, "hello", agentpkg.WithSession(session)) + if err != nil { + t.Fatalf("RunText: %v", err) + } + request := runtime.lastResumeRequest(t) + + assertEqual(t, request["sessionId"], "existing-session", "sessionId") + assertEqual(t, request["model"], "gpt-4o", "model") + assertEqual(t, request["reasoningEffort"], "high", "reasoningEffort") + assertSystemMessage(t, request, "Be helpful") + assertStringSlice(t, request["availableTools"], []string{"tool1", "tool2"}, "availableTools") + assertStringSlice(t, request["excludedTools"], []string{"tool3"}, "excludedTools") + assertEqual(t, request["workingDirectory"], "/workspace", "workingDirectory") + assertEqual(t, request["configDir"], "/config", "configDir") + assertEqual(t, request["requestPermission"], true, "requestPermission") + assertEqual(t, request["requestUserInput"], true, "requestUserInput") + assertEqual(t, request["hooks"], true, "hooks") + if request["mcpServers"] == nil { + t.Fatal("mcpServers was not sent") + } + assertStringSlice(t, request["disabledSkills"], []string{"skill1"}, "disabledSkills") + assertEqual(t, request["streaming"], true, "streaming") +} + +func TestCopySessionConfig_WithStreamingDisabled_PreservesStreamingValue(t *testing.T) { + runtime := newFakeRuntime(t, idleEvent()) + agent := copilotagent.New(runtime.client(), copilotagent.Config{SessionConfig: &copilot.SessionConfig{Streaming: copilot.Bool(false), Model: "gpt-4o"}}) + + _, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + assertEqual(t, runtime.lastCreateRequest(t)["streaming"], false, "streaming") +} + +func TestCopySessionConfig_WithStreamingNull_DefaultsToTrue(t *testing.T) { + runtime := newFakeRuntime(t, idleEvent()) + agent := copilotagent.New(runtime.client(), copilotagent.Config{SessionConfig: &copilot.SessionConfig{Model: "gpt-4o"}}) + + _, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + assertEqual(t, runtime.lastCreateRequest(t)["streaming"], true, "streaming") +} + +func TestCopyResumeSessionConfig_WithStreamingDisabled_PreservesStreamingValue(t *testing.T) { + runtime := newFakeRuntime(t, idleEvent()) + agent := copilotagent.New(runtime.client(), copilotagent.Config{SessionConfig: &copilot.SessionConfig{Streaming: copilot.Bool(false), Model: "gpt-4o"}}) + session, err := agent.CreateSession(context.Background(), agentpkg.WithServiceID("existing-session")) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + _, err = runText(t, agent, "hello", agentpkg.WithSession(session)) + if err != nil { + t.Fatalf("RunText: %v", err) + } + assertEqual(t, runtime.lastResumeRequest(t)["streaming"], false, "streaming") +} + +func TestCopyResumeSessionConfig_WithStreamingNull_DefaultsToTrue(t *testing.T) { + runtime := newFakeRuntime(t, idleEvent()) + agent := copilotagent.New(runtime.client(), copilotagent.Config{SessionConfig: &copilot.SessionConfig{Model: "gpt-4o"}}) + session, err := agent.CreateSession(context.Background(), agentpkg.WithServiceID("existing-session")) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + _, err = runText(t, agent, "hello", agentpkg.WithSession(session)) + if err != nil { + t.Fatalf("RunText: %v", err) + } + assertEqual(t, runtime.lastResumeRequest(t)["streaming"], true, "streaming") +} + +func TestConvertToAgentResponseUpdate_AssistantMessageEventWhenStreaming_DoesNotEmitTextContent(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("assistant.message", map[string]any{"messageId": "msg-456", "content": "Some streamed content that was already delivered via delta events"}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + if got := response.String(); got != "" { + t.Fatalf("response text = %q, want empty", got) + } + _ = firstContent[*message.RawContent](t, response) +} + +func TestConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_EmitsTextContent(t *testing.T) { + const expected = "Full response text from non-streaming session" + runtime := newFakeRuntime(t, + sessionEvent("assistant.message", map[string]any{"messageId": "msg-789", "content": expected}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello", agentpkg.Stream(false)) + if err != nil { + t.Fatalf("RunText: %v", err) + } + if got := response.String(); got != expected { + t.Fatalf("response text = %q, want %q", got, expected) + } + if text := firstContent[*message.TextContent](t, response); text.Text != expected { + t.Fatalf("text content = %q, want %q", text.Text, expected) + } +} + +func TestConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_HandlesEmptyContent(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("assistant.message", map[string]any{"messageId": "msg-000", "content": ""}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello", agentpkg.Stream(false)) + if err != nil { + t.Fatalf("RunText: %v", err) + } + if got := response.String(); got != "" { + t.Fatalf("response text = %q, want empty", got) + } + _ = firstContent[*message.TextContent](t, response) +} + +func TestConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_HandlesNullData(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("assistant.message", nil), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello", agentpkg.Stream(false)) + if err != nil { + t.Fatalf("RunText: %v", err) + } + if got := response.String(); got != "" { + t.Fatalf("response text = %q, want empty", got) + } + _ = firstContent[*message.TextContent](t, response) + if len(response.Messages) == 0 || response.Messages[0].ID != "" { + t.Fatalf("message ID = %#v, want empty", response.Messages) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionStartEvent_ProducesFunctionCallContent(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_start", map[string]any{"toolCallId": "call-123", "toolName": "readFile", "arguments": map[string]any{"path": "/tmp/test.txt"}}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{Config: agentpkg.Config{ID: "agent-1"}}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + if got := response.AgentID; got != "agent-1" { + t.Fatalf("AgentID = %q, want agent-1", got) + } + call := firstContent[*message.FunctionCallContent](t, response) + if call.CallID != "call-123" || call.Name != "readFile" { + t.Fatalf("call = (%q, %q), want (call-123, readFile)", call.CallID, call.Name) + } + var args map[string]any + if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil { + t.Fatalf("unmarshal arguments: %v", err) + } + if args["path"] != "/tmp/test.txt" { + t.Fatalf("path argument = %#v, want /tmp/test.txt", args["path"]) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNullArguments_ProducesEmptyArguments(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_start", map[string]any{"toolCallId": "call-456", "toolName": "listTools", "arguments": nil}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + call := firstContent[*message.FunctionCallContent](t, response) + if call.CallID != "call-456" || call.Name != "listTools" { + t.Fatalf("call = (%q, %q), want (call-456, listTools)", call.CallID, call.Name) + } + if call.Arguments != "" { + t.Fatalf("Arguments = %q, want empty", call.Arguments) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNullData_ProducesEmptyFunctionCall(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_start", nil), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + call := firstContent[*message.FunctionCallContent](t, response) + if call.CallID != "" || call.Name != "" || call.Arguments != "" { + t.Fatalf("call = (%q, %q, %q), want empty fields", call.CallID, call.Name, call.Arguments) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithSuccess_ProducesFunctionResultContent(t *testing.T) { + const resultJSON = `{"users":[{"name":"Alice"}]}` + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_complete", map[string]any{"toolCallId": "call-123", "success": true, "result": map[string]any{"content": resultJSON}}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{Config: agentpkg.Config{ID: "agent-2"}}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + if got := response.AgentID; got != "agent-2" { + t.Fatalf("AgentID = %q, want agent-2", got) + } + result := firstContent[*message.FunctionResultContent](t, response) + if result.CallID != "call-123" || result.Result != resultJSON { + t.Fatalf("result = (%q, %#v), want (call-123, %q)", result.CallID, result.Result, resultJSON) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithError_ProducesErrorResult(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_complete", map[string]any{"toolCallId": "call-789", "success": false, "error": map[string]any{"code": "PERMISSION_DENIED", "message": "Access denied to resource"}}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + result := firstContent[*message.FunctionResultContent](t, response) + if result.CallID != "call-789" || result.Result != "Access denied to resource" { + t.Fatalf("result = (%q, %#v), want access denied", result.CallID, result.Result) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithFailureNoError_ProducesDefaultErrorMessage(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_complete", map[string]any{"toolCallId": "call-000", "success": false}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + result := firstContent[*message.FunctionResultContent](t, response) + if result.CallID != "call-000" || result.Result != "Tool execution failed" { + t.Fatalf("result = (%q, %#v), want default failure", result.CallID, result.Result) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithNullData_ProducesEmptyResult(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_complete", nil), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + result := firstContent[*message.FunctionResultContent](t, response) + if result.CallID != "" || result.Result != "Tool execution failed" { + t.Fatalf("result = (%q, %#v), want empty call ID and default failure", result.CallID, result.Result) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithSuccessButNullResult_ProducesNullResult(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_complete", map[string]any{"toolCallId": "call-null-result", "success": true, "result": nil}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + result := firstContent[*message.FunctionResultContent](t, response) + if result.CallID != "call-null-result" || result.Result != nil { + t.Fatalf("result = (%q, %#v), want nil", result.CallID, result.Result) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithEmptyObjectArguments_ProducesEmptyObjectArguments(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_start", map[string]any{"toolCallId": "call-empty", "toolName": "noArgsTool", "arguments": map[string]any{}}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + call := firstContent[*message.FunctionCallContent](t, response) + if call.CallID != "call-empty" || call.Arguments != "{}" { + t.Fatalf("call = (%q, %q), want empty object arguments", call.CallID, call.Arguments) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithMultipleArguments_ParsesAll(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_start", map[string]any{"toolCallId": "call-multi", "toolName": "queryTable", "arguments": map[string]any{"table": "incidents", "limit": 10, "filter": "active=true"}}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + call := firstContent[*message.FunctionCallContent](t, response) + if call.CallID != "call-multi" || call.Name != "queryTable" { + t.Fatalf("call = (%q, %q), want call-multi/queryTable", call.CallID, call.Name) + } + var args map[string]any + if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil { + t.Fatalf("unmarshal arguments: %v", err) + } + if args["table"] != "incidents" || args["limit"] != float64(10) || args["filter"] != "active=true" { + t.Fatalf("arguments = %#v, want all top-level arguments", args) + } +} + +func TestConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNestedJsonArguments_ParsesTopLevel(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("tool.execution_start", map[string]any{"toolCallId": "call-nested", "toolName": "complexTool", "arguments": map[string]any{"config": map[string]any{"timeout": 30}, "name": "test"}}), + idleEvent(), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + call := firstContent[*message.FunctionCallContent](t, response) + var args map[string]any + if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil { + t.Fatalf("unmarshal arguments: %v", err) + } + if args["name"] != "test" || args["config"] == nil { + t.Fatalf("arguments = %#v, want nested config and name", args) + } +} + +func TestRun_WithSessionError_ReturnsDotNetStyleError(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("session.error", map[string]any{"errorType": "query", "message": "Something went wrong"}), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + _, err := runText(t, agent, "hello") + if err == nil || err.Error() != "session error: Something went wrong" { + t.Fatalf("err = %v, want session error: Something went wrong", err) + } +} + +func TestRun_WithSessionErrorMissingMessage_ReturnsUnknownError(t *testing.T) { + runtime := newFakeRuntime(t, + sessionEvent("session.error", map[string]any{"errorType": "query"}), + ) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + _, err := runText(t, agent, "hello") + if err == nil || err.Error() != "session error: unknown error" { + t.Fatalf("err = %v, want session error: unknown error", err) + } +} + +func richSessionConfig() *copilot.SessionConfig { + return &copilot.SessionConfig{ + Model: "gpt-4o", + ReasoningEffort: "high", + SystemMessage: &copilot.SystemMessageConfig{Mode: "append", Content: "Be helpful"}, + AvailableTools: []string{"tool1", "tool2"}, + ExcludedTools: []string{"tool3"}, + WorkingDirectory: "/workspace", + ConfigDirectory: "/config", + InfiniteSessions: &copilot.InfiniteSessionConfig{}, + OnPermissionRequest: func(copilot.PermissionRequest, copilot.PermissionInvocation) (rpc.PermissionDecision, error) { + return &rpc.PermissionDecisionApproveOnce{}, nil + }, + OnUserInputRequest: func(copilot.UserInputRequest, copilot.UserInputInvocation) (copilot.UserInputResponse, error) { + return copilot.UserInputResponse{Answer: "input"}, nil + }, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(copilot.PreToolUseHookInput, copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + return nil, nil + }, + }, + MCPServers: map[string]copilot.MCPServerConfig{ + "server1": copilot.MCPStdioServerConfig{Command: "npx"}, + }, + DisabledSkills: []string{"skill1"}, + } +} + +func runText(t *testing.T, a *agentpkg.Agent, prompt string, options ...agentpkg.Option) (*agentpkg.Response, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return a.RunText(ctx, prompt, options...).Collect() +} + +func firstContent[T message.Content](t *testing.T, response *agentpkg.Response) T { + t.Helper() + for content := range response.Contents() { + if typed, ok := content.(T); ok { + return typed + } + } + var zero T + t.Fatalf("content of type %T not found", zero) + return zero +} + +func sessionEvent(eventType string, data any) map[string]any { + return map[string]any{ + "id": "00000000-0000-0000-0000-000000000001", + "parentId": nil, + "timestamp": "2026-01-01T00:00:00Z", + "type": eventType, + "data": data, + } +} + +func idleEvent() map[string]any { + return sessionEvent("session.idle", map[string]any{}) +} + +type fakeRuntime struct { + t *testing.T + listener net.Listener + + mu sync.Mutex + sessionID string + events []map[string]any + createRequests []map[string]any + resumeRequests []map[string]any + sendRequests []map[string]any +} + +func newFakeRuntime(t *testing.T, events ...map[string]any) *fakeRuntime { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + runtime := &fakeRuntime{t: t, listener: listener, events: events} + t.Cleanup(func() { _ = listener.Close() }) + go runtime.accept() + return runtime +} + +func (r *fakeRuntime) client() *copilot.Client { + return copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.URIConnection{URL: r.listener.Addr().String()}, + }) +} + +func (r *fakeRuntime) lastCreateRequest(t *testing.T) map[string]any { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + if len(r.createRequests) == 0 { + t.Fatal("no session.create request captured") + } + return r.createRequests[len(r.createRequests)-1] +} + +func (r *fakeRuntime) lastResumeRequest(t *testing.T) map[string]any { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + if len(r.resumeRequests) == 0 { + t.Fatal("no session.resume request captured") + } + return r.resumeRequests[len(r.resumeRequests)-1] +} + +func (r *fakeRuntime) accept() { + for { + conn, err := r.listener.Accept() + if err != nil { + return + } + go r.serve(conn) + } +} + +func (r *fakeRuntime) serve(conn net.Conn) { + defer conn.Close() + reader := bufio.NewReader(conn) + for { + payload, err := readFrame(reader) + if err != nil { + if err != io.EOF { + r.t.Logf("fake runtime read: %v", err) + } + return + } + var req jsonRPCRequest + if err := json.Unmarshal(payload, &req); err != nil { + r.t.Logf("fake runtime unmarshal: %v", err) + return + } + r.handle(conn, req) + } +} + +func (r *fakeRuntime) handle(conn net.Conn, req jsonRPCRequest) { + switch req.Method { + case "connect": + writeResponse(r.t, conn, req.ID, map[string]any{"protocolVersion": copilot.SDKProtocolVersion}) + case "session.create": + params := decodeParams(r.t, req.Params) + sessionID, _ := params["sessionId"].(string) + if sessionID == "" { + sessionID = "session-1" + } + r.mu.Lock() + r.sessionID = sessionID + r.createRequests = append(r.createRequests, params) + r.mu.Unlock() + writeResponse(r.t, conn, req.ID, map[string]any{"sessionId": sessionID, "workspacePath": ""}) + case "session.resume": + params := decodeParams(r.t, req.Params) + sessionID, _ := params["sessionId"].(string) + r.mu.Lock() + r.sessionID = sessionID + r.resumeRequests = append(r.resumeRequests, params) + r.mu.Unlock() + writeResponse(r.t, conn, req.ID, map[string]any{"sessionId": sessionID, "workspacePath": ""}) + case "session.send": + params := decodeParams(r.t, req.Params) + r.mu.Lock() + r.sendRequests = append(r.sendRequests, params) + sessionID := r.sessionID + events := append([]map[string]any(nil), r.events...) + r.mu.Unlock() + writeResponse(r.t, conn, req.ID, map[string]any{"messageId": "sent-message"}) + for _, event := range events { + writeNotification(r.t, conn, "session.event", map[string]any{"sessionId": sessionID, "event": event}) + } + case "session.destroy", "runtime.shutdown": + writeResponse(r.t, conn, req.ID, map[string]any{}) + default: + writeResponse(r.t, conn, req.ID, map[string]any{}) + } +} + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +func decodeParams(t *testing.T, raw json.RawMessage) map[string]any { + t.Helper() + var params map[string]any + if len(raw) == 0 { + return params + } + if err := json.Unmarshal(raw, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + return params +} + +func readFrame(reader *bufio.Reader) ([]byte, error) { + var length int + for { + line, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + line = strings.TrimSpace(line) + if line == "" { + break + } + name, value, ok := strings.Cut(line, ":") + if !ok { + return nil, fmt.Errorf("invalid header %q", line) + } + if name == "Content-Length" { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return nil, err + } + length = parsed + } + } + if length == 0 { + return nil, fmt.Errorf("missing Content-Length") + } + payload := make([]byte, length) + _, err := io.ReadFull(reader, payload) + return payload, err +} + +func writeResponse(t *testing.T, writer io.Writer, id json.RawMessage, result any) { + t.Helper() + writeFrame(t, writer, map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(id), "result": result}) +} + +func writeNotification(t *testing.T, writer io.Writer, method string, params any) { + t.Helper() + writeFrame(t, writer, map[string]any{"jsonrpc": "2.0", "method": method, "params": params}) +} + +func writeFrame(t *testing.T, writer io.Writer, value any) { + t.Helper() + payload, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal frame: %v", err) + } + if _, err := fmt.Fprintf(writer, "Content-Length: %d\r\n\r\n", len(payload)); err != nil { + t.Fatalf("write frame header: %v", err) + } + if _, err := writer.Write(payload); err != nil { + t.Fatalf("write frame payload: %v", err) + } +} + +func assertEqual(t *testing.T, got, want any, name string) { + t.Helper() + if got != want { + t.Fatalf("%s = %#v, want %#v", name, got, want) + } +} + +func assertSystemMessage(t *testing.T, request map[string]any, content string) { + t.Helper() + systemMessage, ok := request["systemMessage"].(map[string]any) + if !ok { + t.Fatalf("systemMessage = %#v, want object", request["systemMessage"]) + } + assertEqual(t, systemMessage["content"], content, "systemMessage.content") +} + +func assertStringSlice(t *testing.T, got any, want []string, name string) { + t.Helper() + gotSlice, ok := got.([]any) + if !ok { + t.Fatalf("%s = %#v, want slice", name, got) + } + if len(gotSlice) != len(want) { + t.Fatalf("%s length = %d, want %d", name, len(gotSlice), len(want)) + } + for i, item := range gotSlice { + if item != want[i] { + t.Fatalf("%s[%d] = %#v, want %#v", name, i, item, want[i]) + } + } +} diff --git a/examples/02-agents/providers/github-copilot/main.go b/examples/02-agents/providers/github-copilot/main.go new file mode 100644 index 00000000..bc35f55c --- /dev/null +++ b/examples/02-agents/providers/github-copilot/main.go @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/rpc" + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/provider/copilotagent" + "github.com/microsoft/agent-framework-go/examples/internal/demo" +) + +var logger = demo.NewLogger( + "GitHub Copilot Agent", + "Demonstrates a GitHub Copilot-backed agent with shell command permissions.", +) + +func main() { + ctx := context.Background() + + // Create and start a Copilot client. The SDK uses the bundled Copilot CLI by default, + // or COPILOT_CLI_PATH when set. + copilotClient := copilot.NewClient(nil) + if err := copilotClient.Start(ctx); err != nil { + demo.Panicf("failed to start GitHub Copilot client: %v", err) + } + defer func() { _ = copilotClient.Stop() }() + + a := copilotagent.New( + copilotClient, + copilotagent.Config{ + SessionConfig: &copilot.SessionConfig{ + OnPermissionRequest: promptPermission, + }, + Config: agent.Config{ + Name: "GitHub Copilot Agent", + Middlewares: []agent.Middleware{logger}, + }, + }, + ) + + for update, err := range a.RunText(ctx, "List all files in the current directory", agent.Stream(true)) { + if err != nil { + demo.Panic(err) + } + fmt.Print(update) + } +} + +func promptPermission(request copilot.PermissionRequest, _ copilot.PermissionInvocation) (rpc.PermissionDecision, error) { + fmt.Printf("\n[Permission Request: %s]\n", request.Kind()) + fmt.Print("Approve? (y/n): ") + + input, _ := bufio.NewReader(os.Stdin).ReadString('\n') + input = strings.TrimSpace(strings.ToUpper(input)) + if input == "Y" || input == "YES" { + return &rpc.PermissionDecisionApproveOnce{}, nil + } + return &rpc.PermissionDecisionReject{}, nil +} diff --git a/go.mod b/go.mod index 06a2139f..50c69b27 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/a2aproject/a2a-go/v2 v2.3.1 github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260312103001-8e7ab1df34c8 github.com/anthropics/anthropic-sdk-go v1.51.1 + github.com/github/copilot-sdk/go v1.0.3-0.20260623095818-0667a46d2a94 github.com/gofrs/flock v0.13.0 github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 @@ -30,6 +31,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect diff --git a/go.sum b/go.sum index 762d8b1e..de34b3de 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -42,6 +44,10 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/github/copilot-sdk/go v1.0.2 h1:trjxOqsC9zH4hT1IVQRHwgUcIpIDan6boEEpif/GaXY= +github.com/github/copilot-sdk/go v1.0.2/go.mod h1:I6YKZQzUXhHZd8a2TkSOcBa8FMVL34QiEg2yxiaXghw= +github.com/github/copilot-sdk/go v1.0.3-0.20260623095818-0667a46d2a94 h1:BPagVLuGK7t8aPoCYhjG0PXCGHHIPqv+TQTKft+Uo4M= +github.com/github/copilot-sdk/go v1.0.3-0.20260623095818-0667a46d2a94/go.mod h1:+Qo8WkaIJEb1aqRwzrO49Z9nHWfQcF39iro4fH+qNYQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/internal/jsonx/jsonx.go b/internal/jsonx/jsonx.go index 7e728de0..ca65bbe2 100644 --- a/internal/jsonx/jsonx.go +++ b/internal/jsonx/jsonx.go @@ -11,10 +11,17 @@ import ( // UnmarshalDiscriminatedUnionSlice unmarshals a JSON array of union types into a slice of type T. // The types map should map from a discriminator key to the corresponding reflect.Type of T. func UnmarshalDiscriminatedUnionSlice[T any, K comparable](data []byte, types map[K]reflect.Type) ([]T, error) { - // Unmarshal just the type fields to determine content types. - var items []struct { - Type K - } + return UnmarshalDiscriminatedUnionSliceWithFallback[T, K](data, types, nil) +} + +// UnmarshalDiscriminatedUnionSliceWithFallback unmarshals a JSON array of union types into a slice of type T. +// The fallback function is used when an item has a missing or unsupported discriminator value. +func UnmarshalDiscriminatedUnionSliceWithFallback[T any, K comparable]( + data []byte, + types map[K]reflect.Type, + fallback func(json.RawMessage) (T, error), +) ([]T, error) { + var items []json.RawMessage if err := json.Unmarshal(data, &items); err != nil { return nil, err } @@ -22,16 +29,30 @@ func UnmarshalDiscriminatedUnionSlice[T any, K comparable](data []byte, types ma // Create appropriate content instances based on type. out := make([]T, 0, len(items)) for _, item := range items { - typ, ok := types[item.Type] + var header struct { + Type K + } + if err := json.Unmarshal(item, &header); err != nil { + return nil, err + } + typ, ok := types[header.Type] if !ok { - return nil, fmt.Errorf("unsupported content type: %v", item.Type) + if fallback == nil { + return nil, fmt.Errorf("unsupported content type: %v", header.Type) + } + value, err := fallback(item) + if err != nil { + return nil, err + } + out = append(out, value) + continue } - out = append(out, reflect.New(typ).Interface().(T)) - } - // Unmarshal the full data into the content instances. - if err := json.Unmarshal(data, &out); err != nil { - return nil, err + value := reflect.New(typ).Interface().(T) + if err := json.Unmarshal(item, value); err != nil { + return nil, err + } + out = append(out, value) } return out, nil } diff --git a/internal/jsonx/jsonx_test.go b/internal/jsonx/jsonx_test.go new file mode 100644 index 00000000..640f99ca --- /dev/null +++ b/internal/jsonx/jsonx_test.go @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +package jsonx + +import ( + "encoding/json" + "reflect" + "testing" +) + +type testUnion interface { + testUnion() +} + +type knownUnion struct { + Type string + Value string +} + +func (*knownUnion) testUnion() {} + +type rawUnion struct { + Raw json.RawMessage +} + +func (*rawUnion) testUnion() {} + +func TestUnmarshalDiscriminatedUnionSliceWithFallback_UsesFallbackForMissingAndUnknownTypes(t *testing.T) { + data := []byte(`[{ + "Type":"known", + "Value":"ok" + },{ + "Type":"future", + "Value":42 + },{ + "Value":"missing" + }]`) + types := map[string]reflect.Type{ + "known": reflect.TypeOf(knownUnion{}), + } + fallback := func(raw json.RawMessage) (testUnion, error) { + return &rawUnion{Raw: append(json.RawMessage(nil), raw...)}, nil + } + + values, err := UnmarshalDiscriminatedUnionSliceWithFallback(data, types, fallback) + if err != nil { + t.Fatal(err) + } + if len(values) != 3 { + t.Fatalf("expected 3 values, got %d", len(values)) + } + known, ok := values[0].(*knownUnion) + if !ok { + t.Fatalf("values[0] = %T, want *knownUnion", values[0]) + } + if known.Value != "ok" { + t.Fatalf("known.Value = %q, want ok", known.Value) + } + if _, ok := values[1].(*rawUnion); !ok { + t.Fatalf("values[1] = %T, want *rawUnion", values[1]) + } + if _, ok := values[2].(*rawUnion); !ok { + t.Fatalf("values[2] = %T, want *rawUnion", values[2]) + } +} + +func TestUnmarshalDiscriminatedUnionSlice_RejectsUnsupportedType(t *testing.T) { + _, err := UnmarshalDiscriminatedUnionSlice[testUnion]([]byte(`[{"Type":"future"}]`), map[string]reflect.Type{}) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/message/content.go b/message/content.go index 953b67a8..976e1787 100644 --- a/message/content.go +++ b/message/content.go @@ -74,9 +74,20 @@ type ToolCallContent interface { type Contents []Content func (cs *Contents) UnmarshalJSON(data []byte) error { - var err error - *cs, err = jsonx.UnmarshalDiscriminatedUnionSlice[Content](data, supportedContents) - return err + out, err := jsonx.UnmarshalDiscriminatedUnionSliceWithFallback(data, supportedContents, unmarshalRawContent) + if err != nil { + return err + } + *cs = out + return nil +} + +func unmarshalRawContent(data json.RawMessage) (Content, error) { + var raw RawContent + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + return &raw, nil } // Text returns the first text content in the response, or empty string. @@ -456,6 +467,35 @@ func (t *HostedVectorStoreContent) MarshalJSON() ([]byte, error) { func (t HostedVectorStoreContent) kind() contentKind { return "hostedVectorStore" } +// RawContent represents provider-specific content that does not fit one of the +// structured content types. The provider value is available through +// [ContentHeader.RawRepresentation]. +type RawContent struct { + ContentHeader +} + +func (t *RawContent) MarshalJSON() ([]byte, error) { + if raw, ok := t.RawRepresentation.(json.RawMessage); ok && len(raw) > 0 { + return raw, nil + } + type alias RawContent + return json.Marshal((*alias)(t)) +} + +func (t *RawContent) UnmarshalJSON(data []byte) error { + if !json.Valid(data) { + return fmt.Errorf("invalid raw content JSON") + } + var header ContentHeader + if err := json.Unmarshal(data, &header); err == nil { + t.ContentHeader = header + } + t.RawRepresentation = append(json.RawMessage(nil), data...) + return nil +} + +func (t RawContent) kind() contentKind { return "" } + // TextReasoningContent represents text reasoning content in a chat. // // TextReasoningContent is distinct from [TextContent]. TextReasoningContent represents "thinking" or "reasoning" diff --git a/message/content_test.go b/message/content_test.go index 4e04e976..5b129dd0 100644 --- a/message/content_test.go +++ b/message/content_test.go @@ -183,6 +183,61 @@ func TestContentEncoding_Roundtrip(t *testing.T) { } } +func TestContentEncoding_UnmarshalMissingTypeUsesRawContent(t *testing.T) { + const rawContent = `{"Provider":"github","Payload":{"value":42}}` + data := []byte(`[` + rawContent + `]`) + + var contents message.Contents + if err := json.Unmarshal(data, &contents); err != nil { + t.Fatal(err) + } + if len(contents) != 1 { + t.Fatalf("expected 1 content, got %d", len(contents)) + } + raw, ok := contents[0].(*message.RawContent) + if !ok { + t.Fatalf("content = %T, want *message.RawContent", contents[0]) + } + if got := string(raw.Header().RawRepresentation.(json.RawMessage)); got != rawContent { + t.Fatalf("RawRepresentation = %s, want %s", got, rawContent) + } +} + +func TestContentEncoding_UnmarshalUnknownTypeUsesRawContent(t *testing.T) { + const rawContent = `{"Type":"futureContent","Value":42}` + data := []byte(`[` + rawContent + `]`) + + var contents message.Contents + if err := json.Unmarshal(data, &contents); err != nil { + t.Fatal(err) + } + raw, ok := contents[0].(*message.RawContent) + if !ok { + t.Fatalf("content = %T, want *message.RawContent", contents[0]) + } + if got := string(raw.Header().RawRepresentation.(json.RawMessage)); got != rawContent { + t.Fatalf("RawRepresentation = %s, want %s", got, rawContent) + } + + encoded, err := json.Marshal(contents) + if err != nil { + t.Fatal(err) + } + if got := string(encoded); got != string(data) { + t.Fatalf("encoded = %s, want %s", got, data) + } +} + +func TestContentEncoding_RawContentMarshalHasNoType(t *testing.T) { + data, err := json.Marshal(message.Contents{&message.RawContent{}}) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), `[{}]`; got != want { + t.Fatalf("encoded = %s, want %s", got, want) + } +} + func TestToolApprovalRequestContent_CreateResponseSnapshotsFunctionCall(t *testing.T) { toolCall := &message.FunctionCallContent{ ContentHeader: message.ContentHeader{ From 4a15861e5e5ddae2f6bf811c57d244c3a0502179 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Wed, 24 Jun 2026 12:01:27 +0200 Subject: [PATCH 2/2] Address Copilot provider PR feedback Fix golangci-lint findings, make Copilot session event delivery non-blocking while preserving event order, avoid duplicate attachment temp paths, and distinguish missing discriminators from zero-value discriminators in jsonx fallback unmarshalling. --- agent/provider/copilotagent/copilot.go | 149 +++++++++++++------- agent/provider/copilotagent/copilot_test.go | 81 ++++++++++- internal/jsonx/jsonx.go | 18 ++- internal/jsonx/jsonx_test.go | 26 ++++ 4 files changed, 219 insertions(+), 55 deletions(-) diff --git a/agent/provider/copilotagent/copilot.go b/agent/provider/copilotagent/copilot.go index bc2a22eb..962c9f12 100644 --- a/agent/provider/copilotagent/copilot.go +++ b/agent/provider/copilotagent/copilot.go @@ -13,6 +13,7 @@ import ( "path/filepath" "slices" "strings" + "sync" copilot "github.com/github/copilot-sdk/go" "github.com/microsoft/agent-framework-go/agent" @@ -77,12 +78,14 @@ func (p *provider) run(ctx context.Context, messages []*message.Message, options return } - events := make(chan copilot.SessionEvent, 128) + events := newSessionEventQueue() eventHandler := func(event copilot.SessionEvent) { select { - case events <- event: case <-ctx.Done(): + return + default: } + events.push(event) } frameworkSession, _ := agent.GetOption(options, agent.WithSession) @@ -113,48 +116,94 @@ func (p *provider) run(ctx context.Context, messages []*message.Message, options } for { - select { - case event := <-events: - var update *agent.ResponseUpdate - var done bool - var eventErr error - switch data := event.Data.(type) { - case *copilot.AssistantMessageDeltaData: - update = p.assistantMessageDeltaUpdate(event, data) - case *copilot.AssistantMessageData: - update = p.assistantMessageUpdate(event, data, isStreaming) - case *copilot.ToolExecutionStartData: - update = p.toolExecutionStartUpdate(event, data) - case *copilot.ToolExecutionCompleteData: - update = p.toolExecutionCompleteUpdate(event, data) - case *copilot.AssistantUsageData: - update = p.assistantUsageUpdate(event, data) - case *copilot.SessionIdleData: - update = rawEventUpdate(event) - done = true - case *copilot.SessionErrorData: - update = rawEventUpdate(event) - done = true - eventErr = fmt.Errorf("session error: %s", sessionErrorMessage(data)) - default: - update = rawEventUpdate(event) - } - if update != nil { - if !yield(update, nil) { - return - } - } - if eventErr != nil { - yield(nil, eventErr) - return - } - if done { + event, err := events.pop(ctx) + if err != nil { + yield(nil, err) + return + } + var update *agent.ResponseUpdate + var done bool + var eventErr error + switch data := event.Data.(type) { + case *copilot.AssistantMessageDeltaData: + update = p.assistantMessageDeltaUpdate(event, data) + case *copilot.AssistantMessageData: + update = p.assistantMessageUpdate(event, data, isStreaming) + case *copilot.ToolExecutionStartData: + update = p.toolExecutionStartUpdate(event, data) + case *copilot.ToolExecutionCompleteData: + update = p.toolExecutionCompleteUpdate(event, data) + case *copilot.AssistantUsageData: + update = p.assistantUsageUpdate(event, data) + case *copilot.SessionIdleData: + update = rawEventUpdate(event) + done = true + case *copilot.SessionErrorData: + update = rawEventUpdate(event) + done = true + eventErr = fmt.Errorf("session error: %s", sessionErrorMessage(data)) + default: + update = rawEventUpdate(event) + } + if update != nil { + if !yield(update, nil) { return } - case <-ctx.Done(): - yield(nil, ctx.Err()) + } + if eventErr != nil { + yield(nil, eventErr) return } + if done { + return + } + } + } +} + +type sessionEventQueue struct { + mu sync.Mutex + items []copilot.SessionEvent + ready chan struct{} +} + +func newSessionEventQueue() *sessionEventQueue { + return &sessionEventQueue{ready: make(chan struct{}, 1)} +} + +func (q *sessionEventQueue) push(event copilot.SessionEvent) { + q.mu.Lock() + wasEmpty := len(q.items) == 0 + q.items = append(q.items, event) + q.mu.Unlock() + if wasEmpty { + select { + case q.ready <- struct{}{}: + default: + } + } +} + +func (q *sessionEventQueue) pop(ctx context.Context) (copilot.SessionEvent, error) { + for { + q.mu.Lock() + if len(q.items) > 0 { + event := q.items[0] + var zero copilot.SessionEvent + q.items[0] = zero + q.items = q.items[1:] + if len(q.items) == 0 { + q.items = nil + } + q.mu.Unlock() + return event, nil + } + q.mu.Unlock() + + select { + case <-q.ready: + case <-ctx.Done(): + return copilot.SessionEvent{}, ctx.Err() } } } @@ -204,9 +253,9 @@ func copySessionConfig(source *copilot.SessionConfig) copilot.SessionConfig { if source == nil { return copilot.SessionConfig{Streaming: copilot.Bool(true)} } - copy := *source - copy.Streaming = copyBoolDefaultTrue(source.Streaming) - return copy + clone := *source + clone.Streaming = copyBoolDefaultTrue(source.Streaming) + return clone } func copyResumeSessionConfig(source *copilot.SessionConfig) copilot.ResumeSessionConfig { @@ -255,13 +304,13 @@ func systemMessageWithInstructions(base *copilot.SystemMessageConfig, instructio Content: joined, } } - copy := *base - if copy.Content == "" { - copy.Content = joined + clone := *base + if clone.Content == "" { + clone.Content = joined } else { - copy.Content += "\n" + joined + clone.Content += "\n" + joined } - return © + return &clone } func compactInstructions(instructions []string) []string { @@ -443,8 +492,8 @@ func saveDataContentAttachment(tempDir string, index int, content *message.DataC if displayName == "." || displayName == string(filepath.Separator) || displayName == "" { displayName = fmt.Sprintf("attachment-%d", index+1) } - path := filepath.Join(tempDir, displayName) - if err := os.WriteFile(path, data, 0600); err != nil { + path := filepath.Join(tempDir, fmt.Sprintf("%d-%s", index+1, displayName)) + if err := os.WriteFile(path, data, 0o600); err != nil { return "", "", err } return path, displayName, nil diff --git a/agent/provider/copilotagent/copilot_test.go b/agent/provider/copilotagent/copilot_test.go index 18cf4f34..7fe03ba4 100644 --- a/agent/provider/copilotagent/copilot_test.go +++ b/agent/provider/copilotagent/copilot_test.go @@ -5,6 +5,7 @@ package copilotagent_test import ( "bufio" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -527,6 +528,60 @@ func TestRun_WithSessionErrorMissingMessage_ReturnsUnknownError(t *testing.T) { } } +func TestRun_WithBurstOfStreamingEvents_Completes(t *testing.T) { + const eventCount = 200 + events := make([]map[string]any, 0, eventCount+1) + var expected strings.Builder + for range eventCount { + expected.WriteString("x") + events = append(events, sessionEvent("assistant.message_delta", map[string]any{"messageId": "msg-1", "deltaContent": "x"})) + } + events = append(events, idleEvent()) + runtime := newFakeRuntime(t, events...) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + + response, err := runText(t, agent, "hello") + if err != nil { + t.Fatalf("RunText: %v", err) + } + if got := response.String(); got != expected.String() { + t.Fatalf("response text length = %d, want %d", len(got), expected.Len()) + } +} + +func TestBuildMessageOptions_WithDuplicateAttachmentNames_SendsDistinctPaths(t *testing.T) { + runtime := newFakeRuntime(t, idleEvent()) + agent := copilotagent.New(runtime.client(), copilotagent.Config{}) + contents := []message.Content{ + dataContent(t, "duplicate.txt", "first"), + dataContent(t, "duplicate.txt", "second"), + } + + _, err := agent.Run(context.Background(), []*message.Message{message.New(contents...)}).Collect() + if err != nil { + t.Fatalf("Run: %v", err) + } + attachments := runtime.lastSendAttachments(t) + if len(attachments) != 2 { + t.Fatalf("attachments length = %d, want 2", len(attachments)) + } + if attachments[0]["path"] == attachments[1]["path"] { + t.Fatalf("attachment paths are equal: %#v", attachments) + } + if attachments[0]["displayName"] != "duplicate.txt" || attachments[1]["displayName"] != "duplicate.txt" { + t.Fatalf("display names = %#v, want duplicate.txt", attachments) + } +} + +func dataContent(t *testing.T, name, value string) *message.DataContent { + t.Helper() + return &message.DataContent{ + Name: name, + Data: base64.StdEncoding.EncodeToString([]byte(value)), + MediaType: "text/plain", + } +} + func richSessionConfig() *copilot.SessionConfig { return &copilot.SessionConfig{ Model: "gpt-4o", @@ -638,6 +693,28 @@ func (r *fakeRuntime) lastResumeRequest(t *testing.T) map[string]any { return r.resumeRequests[len(r.resumeRequests)-1] } +func (r *fakeRuntime) lastSendAttachments(t *testing.T) []map[string]any { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + if len(r.sendRequests) == 0 { + t.Fatal("no session.send request captured") + } + attachments, ok := r.sendRequests[len(r.sendRequests)-1]["attachments"].([]any) + if !ok { + t.Fatalf("attachments = %#v, want slice", r.sendRequests[len(r.sendRequests)-1]["attachments"]) + } + out := make([]map[string]any, 0, len(attachments)) + for _, attachment := range attachments { + typed, ok := attachment.(map[string]any) + if !ok { + t.Fatalf("attachment = %#v, want object", attachment) + } + out = append(out, typed) + } + return out +} + func (r *fakeRuntime) accept() { for { conn, err := r.listener.Accept() @@ -649,7 +726,7 @@ func (r *fakeRuntime) accept() { } func (r *fakeRuntime) serve(conn net.Conn) { - defer conn.Close() + defer func() { _ = conn.Close() }() reader := bufio.NewReader(conn) for { payload, err := readFrame(reader) @@ -761,7 +838,7 @@ func readFrame(reader *bufio.Reader) ([]byte, error) { func writeResponse(t *testing.T, writer io.Writer, id json.RawMessage, result any) { t.Helper() - writeFrame(t, writer, map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(id), "result": result}) + writeFrame(t, writer, map[string]any{"jsonrpc": "2.0", "id": id, "result": result}) } func writeNotification(t *testing.T, writer io.Writer, method string, params any) { diff --git a/internal/jsonx/jsonx.go b/internal/jsonx/jsonx.go index ca65bbe2..12851c36 100644 --- a/internal/jsonx/jsonx.go +++ b/internal/jsonx/jsonx.go @@ -30,15 +30,27 @@ func UnmarshalDiscriminatedUnionSliceWithFallback[T any, K comparable]( out := make([]T, 0, len(items)) for _, item := range items { var header struct { - Type K + Type *K } if err := json.Unmarshal(item, &header); err != nil { return nil, err } - typ, ok := types[header.Type] + if header.Type == nil { + if fallback == nil { + var zero K + return nil, fmt.Errorf("unsupported content type: %v", zero) + } + value, err := fallback(item) + if err != nil { + return nil, err + } + out = append(out, value) + continue + } + typ, ok := types[*header.Type] if !ok { if fallback == nil { - return nil, fmt.Errorf("unsupported content type: %v", header.Type) + return nil, fmt.Errorf("unsupported content type: %v", *header.Type) } value, err := fallback(item) if err != nil { diff --git a/internal/jsonx/jsonx_test.go b/internal/jsonx/jsonx_test.go index 640f99ca..a475fd2e 100644 --- a/internal/jsonx/jsonx_test.go +++ b/internal/jsonx/jsonx_test.go @@ -64,6 +64,32 @@ func TestUnmarshalDiscriminatedUnionSliceWithFallback_UsesFallbackForMissingAndU } } +func TestUnmarshalDiscriminatedUnionSliceWithFallback_MissingTypeDoesNotMatchZeroValue(t *testing.T) { + data := []byte(`[{ + "Type":"", + "Value":"empty" + },{ + "Value":"missing" + }]`) + types := map[string]reflect.Type{ + "": reflect.TypeOf(knownUnion{}), + } + fallback := func(raw json.RawMessage) (testUnion, error) { + return &rawUnion{Raw: raw}, nil + } + + values, err := UnmarshalDiscriminatedUnionSliceWithFallback(data, types, fallback) + if err != nil { + t.Fatal(err) + } + if _, ok := values[0].(*knownUnion); !ok { + t.Fatalf("values[0] = %T, want *knownUnion", values[0]) + } + if _, ok := values[1].(*rawUnion); !ok { + t.Fatalf("values[1] = %T, want *rawUnion", values[1]) + } +} + func TestUnmarshalDiscriminatedUnionSlice_RejectsUnsupportedType(t *testing.T) { _, err := UnmarshalDiscriminatedUnionSlice[testUnion]([]byte(`[{"Type":"future"}]`), map[string]reflect.Type{}) if err == nil {