Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7311b685f6 | |||
| bff916a3ac | |||
| b8c03dafa2 | |||
| 04bcef47ac | |||
| e92978f3eb | |||
| 695a24432a | |||
| 2889b98dcf | |||
| a356ab36a8 | |||
| 2078748e7f | |||
| d283f9b08b | |||
| af7b4f3d50 | |||
| 954f8fa79e | |||
| cdfe9c0947 | |||
| c645c5faa7 | |||
| 8bde01bdac | |||
| 1373ceec21 | |||
| 838b7f73f8 |
@@ -21,11 +21,12 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Parse multi-event A2A SSE fallback responses in the harness** ([#3662](https://github.com/micro/go-micro/issues/3662)) — PR #3756 closed the notification-side-effect gap (#3736), so the highest-value remaining Now-phase adoption blocker is streaming correctness: the A2A harness should accept legitimate multi-event `message/stream` responses instead of concatenating valid SSE events into invalid JSON. This keeps first-agent, cross-agent, and 0→hero interop failures focused on real gateway/provider defects rather than parser brittleness.
|
||||
2. **Isolate file-store tests from shared default directory** ([#3751](https://github.com/micro/go-micro/issues/3751)) — the autonomous loop depends on green CI, and the latest triage found a focused unit-test isolation defect where file-store tests share and remove the default database directory. Keep this immediately after the user-visible A2A blocker because a flaky evaluator erodes the loop's ability to ship adoption work safely.
|
||||
3. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — the highest-value remaining resilience gap is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries, live-provider deadline tuning, delegated-plan completion, and side-effect enforcement are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
|
||||
4. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
|
||||
5. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
|
||||
1. **Add CLI wayfinding for first-agent and 0-to-hero docs** ([#3801](https://github.com/micro/go-micro/issues/3801)) — the README and website now tell a much stronger adoption story, but the CLI is the product surface developers touch during scaffold → run → chat → inspect → deploy. Put the walkable no-secret first-agent, debugging, and 0→hero paths directly in the help/docs trail so the newly stabilized harness becomes discoverable, not just documented.
|
||||
2. **Isolate file-store tests from shared default directory** ([#3751](https://github.com/micro/go-micro/issues/3751)) — repeated `go test -race -cover ./...` failures still point at file-store reliability under the shared default directory/table setup. This remains the highest-value store/CI stability issue because a flaky evaluator erodes the loop's ability to ship adoption work safely.
|
||||
3. **Stabilize file-store suffix expiry test timing** ([#3780](https://github.com/micro/go-micro/issues/3780)) — the newer store failure is a narrower timing-sensitive suffix-expiry assertion under `-race -cover`. Keep it adjacent to #3751 but separate because it may need a focused TTL/assertion fix even if directory isolation improves the broader file-store tests.
|
||||
4. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — once the live harness and red CI blockers are cleared, the highest-value remaining resilience gap is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries, live-provider deadline tuning, delegated-plan completion, and side-effect enforcement are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
|
||||
5. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
|
||||
6. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
providers:
|
||||
description: "Comma-separated providers for live conformance (default: all supported)"
|
||||
required: false
|
||||
default: "anthropic,openai,gemini,groq,mistral,together,atlascloud"
|
||||
default: "anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud"
|
||||
harnesses:
|
||||
description: "Comma-separated harnesses for live conformance"
|
||||
required: false
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
@@ -73,7 +74,7 @@ jobs:
|
||||
# catalog id differs (Atlas uses org/model ids).
|
||||
ATLASCLOUD_MODEL: ${{ vars.ATLASCLOUD_MODEL || 'minimaxai/minimax-m3' }}
|
||||
run: |
|
||||
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,mistral,together,atlascloud' }}"
|
||||
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud' }}"
|
||||
HARNESSES="${{ github.event.inputs.harnesses || 'agent,universe,agent-flow,plan-delegate,a2a-stream-fallback' }}"
|
||||
REQUIRE_CONFIGURED="${{ github.event.inputs.require_configured || 'false' }}"
|
||||
|
||||
|
||||
@@ -16,9 +16,30 @@ next version when it ships.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **MiniMax provider** — run agents against MiniMax's `MiniMax-M3` model via its OpenAI-compatible endpoint, with tool calling and streaming; auto-detected from the base URL. (`ai/minimax/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate completion** — agents now continue unfinished plan steps more reliably, fail checkpointed runs that leave delegated plans unfinished, recover from unknown plan-delegate tool calls, avoid duplicate side effects, and complete timeout paths deterministically. (`agent/`)
|
||||
- **AtlasCloud tool calls** — streaming and request fallback handling now recovers tool-call results from provider responses that omit the expected structured fields. (`ai/atlascloud/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.13] - July 2026
|
||||
|
||||
### Added
|
||||
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows dispatched to an @mention-driven coding agent, across up to five roles — `planner` (ranked queue), `builder` (top item as a single-concern PR, auto-merged on green CI), `triage` (CI failures → fix issues), and opt-in `coherence` (docs/CHANGELOG alignment) and `release` (daily patch tag). Each dispatch role's instruction lives in an editable `.github/loop/prompts/<role>.md` file — the workflow is the mechanism, the prompt is the policy — so a repo customizes behavior without forking the CLI. `micro loop init --roles …` writes it all; `micro loop verify` checks the wiring. This is the loop that maintains go-micro itself, generalized. (`cmd/micro/loop/`)
|
||||
|
||||
### Changed
|
||||
- **x402 payments** — settlement now covers CDP facilitator authentication and conformance edge cases. (`wrapper/x402/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate harnessing** — side effects and notifications are now idempotent and deterministic across duplicate, alias, order-scoped, and reachability scenarios. (`agent/`, `internal/harness/`)
|
||||
|
||||
### Documentation
|
||||
- **First-agent on-ramp** — quickstart docs now connect the no-secret first-agent transcript, example map, and 0→hero path. (`README.md`, `internal/website/docs/`)
|
||||
- **Ollama provider docs** — the provider surface, capability matrix, and examples now document local and cloud behavior. (`internal/website/docs/`, `examples/agent-ollama/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.12] - July 2026
|
||||
|
||||
@@ -332,7 +332,7 @@ MCP exposes your services as tools; A2A exposes your agents as agents. See the [
|
||||
| MCP gateway | Every endpoint is an AI tool automatically |
|
||||
| A2A gateway | Every agent is reachable over the Agent2Agent protocol; cards generated from the registry (`micro a2a`) |
|
||||
| Payments (x402) | Opt-in per-call payments for tools via the x402 standard; pluggable facilitator (Base, Solana, …) |
|
||||
| 8 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, Ollama (local + cloud) |
|
||||
| 9 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, MiniMax, Ollama (local + cloud) |
|
||||
| Interactive console | `micro run` includes a chat console for talking to services |
|
||||
| Service generation | `micro run --prompt` — describe a system, get running services |
|
||||
|
||||
@@ -429,6 +429,7 @@ Swap providers with a single import — same interface everywhere:
|
||||
| Mistral | `mistral-large-latest` |
|
||||
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
|
||||
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
|
||||
| MiniMax | `MiniMax-M3` |
|
||||
| Ollama | `llama3.2` (local) |
|
||||
|
||||
```go
|
||||
|
||||
@@ -300,6 +300,20 @@ Default base URL: `https://api.atlascloud.ai`
|
||||
|
||||
Atlas Cloud is an enterprise AI infrastructure platform offering high-performance LLM APIs. It exposes an OpenAI-compatible chat completions endpoint with tool calling support.
|
||||
|
||||
### MiniMax
|
||||
|
||||
```go
|
||||
m := ai.New("minimax",
|
||||
ai.WithAPIKey("your-key"),
|
||||
ai.WithModel("MiniMax-M3"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `MiniMax-M3`
|
||||
Default base URL: `https://api.minimax.io`
|
||||
|
||||
MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible chat completions endpoint.
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
Use `AutoDetectProvider()` to detect the provider from a base URL:
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/minimax"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
|
||||
func TestRegisteredProviders(t *testing.T) {
|
||||
got := ai.RegisteredProviders("")
|
||||
want := []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"}
|
||||
want := []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders() = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -34,7 +35,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{"atlascloud", "groq", "mistral", "openai", "together"}
|
||||
want = []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -47,6 +48,7 @@ func TestCapabilityRows(t *testing.T) {
|
||||
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}},
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
{Provider: "minimax", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
|
||||
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
@@ -59,7 +61,7 @@ func TestCapabilityRows(t *testing.T) {
|
||||
func TestCapabilityMatrix(t *testing.T) {
|
||||
matrix := ai.CapabilityMatrix()
|
||||
|
||||
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"} {
|
||||
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"} {
|
||||
caps, ok := matrix[provider]
|
||||
if !ok {
|
||||
t.Fatalf("CapabilityMatrix missing %q", provider)
|
||||
@@ -88,7 +90,7 @@ func TestRegisterStream(t *testing.T) {
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"atlascloud", "groq", "mistral", "openai", "test-stream", "together"}
|
||||
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Package minimax implements the MiniMax model provider.
|
||||
//
|
||||
// MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible
|
||||
// chat completions endpoint.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import _ "go-micro.dev/v6/ai/minimax"
|
||||
//
|
||||
// m := ai.New("minimax",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/ai/internal/openaiapi"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("minimax", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("minimax")
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
if options.Model == "" {
|
||||
options.Model = "MiniMax-M3"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.minimax.io"
|
||||
}
|
||||
return &Provider{opts: options}
|
||||
}
|
||||
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Options() ai.Options { return p.opts }
|
||||
func (p *Provider) String() string { return "minimax" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
var tools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
}
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMessage["content"],
|
||||
"tool_calls": rawMessage["tool_calls"],
|
||||
})
|
||||
for _, tc := range resp.ToolCalls {
|
||||
content := p.opts.ToolHandler(ctx, tc).Content
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
followUpResp, _, err := p.callAPI(ctx, map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
})
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, nil, fmt.Errorf("no response from API")
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
response := &ai.Response{Reply: choice.Message.Content}
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
if NewProvider().String() != "minimax" {
|
||||
t.Errorf("got %q", NewProvider().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
opts := NewProvider().Options()
|
||||
if opts.Model != "MiniMax-M3" {
|
||||
t.Errorf("default model = %q", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.minimax.io" {
|
||||
t.Errorf("default base URL = %q", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if err := p.Init(ai.WithModel("m"), ai.WithAPIKey("k")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Options().Model != "m" || p.Options().APIKey != "k" {
|
||||
t.Error("Init did not apply options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
if _, err := NewProvider().Generate(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error without API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
var sawStream bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
sawStream, _ = body["stream"].(bool)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawStream {
|
||||
t.Fatal("stream request did not set stream=true")
|
||||
}
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
second, err := stream.Recv()
|
||||
if err != nil || second.Reply != "lo" {
|
||||
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
|
||||
}
|
||||
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final error = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("minimax", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
t.Fatal("provider not registered")
|
||||
}
|
||||
if m.String() != "minimax" {
|
||||
t.Errorf("got %q", m.String())
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,8 @@ func AutoDetectProvider(baseURL string) string {
|
||||
return "gemini"
|
||||
case strings.Contains(baseURL, "groq"):
|
||||
return "groq"
|
||||
case strings.Contains(baseURL, "minimax"):
|
||||
return "minimax"
|
||||
case strings.Contains(baseURL, "mistral"):
|
||||
return "mistral"
|
||||
case strings.Contains(baseURL, "together"):
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/minimax"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
@@ -278,6 +279,7 @@ func conformingStreamProviders(t *testing.T) []string {
|
||||
allowed := map[string]struct{}{
|
||||
"atlascloud": {},
|
||||
"groq": {},
|
||||
"minimax": {},
|
||||
"mistral": {},
|
||||
"openai": {},
|
||||
"together": {},
|
||||
@@ -288,7 +290,7 @@ func conformingStreamProviders(t *testing.T) []string {
|
||||
out = append(out, provider)
|
||||
}
|
||||
}
|
||||
want := []string{"atlascloud", "groq", "mistral", "openai", "together"}
|
||||
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("conforming stream providers = %#v, want %#v (registered stream providers: %#v)", out, want, providers)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,30 @@ import (
|
||||
_ "go-micro.dev/v6/cmd/micro/cli/remote"
|
||||
)
|
||||
|
||||
const docsWayfinding = `First-agent and 0→hero docs:
|
||||
|
||||
1. No-secret first-agent transcript
|
||||
https://go-micro.dev/docs/guides/no-secret-first-agent.html
|
||||
Run the maintained support agent without a provider key:
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
|
||||
2. Your First Agent
|
||||
https://go-micro.dev/docs/guides/your-first-agent.html
|
||||
Build a service-backed agent, then use:
|
||||
micro agent preflight
|
||||
micro run
|
||||
micro chat
|
||||
|
||||
3. Debugging your agent
|
||||
https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
Inspect agent runs and memory with:
|
||||
micro inspect agent
|
||||
micro runs <agent>
|
||||
|
||||
4. 0→hero Reference
|
||||
https://go-micro.dev/docs/guides/zero-to-hero.html
|
||||
Walk the scaffold → run → chat → inspect → deploy dry-run lifecycle.`
|
||||
|
||||
func genProtoHandler(c *cli.Context) error {
|
||||
cmd := exec.Command("find", ".", "-name", "*.proto", "-exec", "protoc", "--proto_path=.", "--micro_out=.", "--go_out=.", `{}`, `;`)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -96,6 +120,17 @@ func init() {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docs",
|
||||
Usage: "Show the first-agent and 0→hero documentation path",
|
||||
Description: `Print the maintained adoption on-ramp for new Go Micro developers:
|
||||
the no-secret first-agent transcript, Your First Agent, debugging guide, and
|
||||
0→hero lifecycle reference.`,
|
||||
Action: func(ctx *cli.Context) error {
|
||||
fmt.Fprintln(ctx.App.Writer, docsWayfinding)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "call",
|
||||
Usage: "Call a service",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -21,7 +22,7 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent"} {
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent", "docs"} {
|
||||
if !commands[want] {
|
||||
t.Fatalf("first-agent walkthrough missing %q command", want)
|
||||
}
|
||||
@@ -37,6 +38,31 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") {
|
||||
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
|
||||
}
|
||||
|
||||
docs := commandByName(t, "docs")
|
||||
if !strings.Contains(docs.Usage, "first-agent") || !strings.Contains(docs.Usage, "0→hero") {
|
||||
t.Fatalf("micro docs should advertise the first-agent and 0→hero docs path; usage was %q", docs.Usage)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
app := cli.NewApp()
|
||||
app.Writer = &out
|
||||
if err := docs.Action(cli.NewContext(app, nil, nil)); err != nil {
|
||||
t.Fatalf("micro docs failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"no-secret-first-agent.html",
|
||||
"your-first-agent.html",
|
||||
"debugging-agents.html",
|
||||
"zero-to-hero.html",
|
||||
"micro agent preflight",
|
||||
"micro run",
|
||||
"micro chat",
|
||||
"micro inspect agent",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro docs output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func commandByName(t *testing.T, name string) *cli.Command {
|
||||
|
||||
@@ -166,22 +166,48 @@ func main() {
|
||||
|
||||
func readSSEData(r io.Reader) (string, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
var event strings.Builder
|
||||
var payload strings.Builder
|
||||
seen := false
|
||||
flush := func() error {
|
||||
data := strings.TrimSpace(event.String())
|
||||
event.Reset()
|
||||
if data == "" {
|
||||
return nil
|
||||
}
|
||||
if !json.Valid([]byte(data)) {
|
||||
return fmt.Errorf("SSE data event is not JSON: %s", data)
|
||||
}
|
||||
seen = true
|
||||
payload.WriteString(data)
|
||||
payload.WriteByte('\n')
|
||||
return nil
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if data, ok := strings.CutPrefix(line, "data: "); ok {
|
||||
payload.WriteString(data)
|
||||
payload.WriteByte('\n')
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if err := flush(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
data, ok := strings.CutPrefix(line, "data:")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if event.Len() > 0 {
|
||||
event.WriteByte('\n')
|
||||
}
|
||||
event.WriteString(strings.TrimSpace(data))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if payload.Len() == 0 {
|
||||
return "", errors.New("no SSE data received")
|
||||
if err := flush(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !json.Valid([]byte(strings.TrimSpace(payload.String()))) {
|
||||
return "", fmt.Errorf("SSE data is not JSON: %s", payload.String())
|
||||
if !seen {
|
||||
return "", errors.New("no SSE data received")
|
||||
}
|
||||
return payload.String(), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadSSEDataAcceptsMultipleJSONEvents(t *testing.T) {
|
||||
payload, err := readSSEData(strings.NewReader("event: status\ndata: {\"phase\":\"started\"}\n\ndata:{\"result\":\"a2a-fallback-ok\"}\n\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("readSSEData returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(payload, "started") || !strings.Contains(payload, "a2a-fallback-ok") {
|
||||
t.Fatalf("payload = %q, want both event payloads", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSSEDataRejectsInvalidJSONEvent(t *testing.T) {
|
||||
_, err := readSSEData(strings.NewReader("data: {\"ok\":true}\n\ndata: {bad json}\n\n"))
|
||||
if err == nil {
|
||||
t.Fatal("readSSEData returned nil error for invalid JSON event")
|
||||
}
|
||||
}
|
||||
@@ -413,7 +413,10 @@ func runPlanDelegate(provider string) error {
|
||||
executeDone <- f.Execute(ctx, "launch readiness")
|
||||
}()
|
||||
|
||||
if err := waitForPlanDelegateExecution(executeDone, notifySvc); err != nil {
|
||||
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := conductor.Ask(ctx, "The Design, Build, and Ship tasks already exist, but the owner notification is still missing. Delegate exactly one notification to the \"comms\" agent now: ask comms to notify owner@acme.com that the launch plan is ready. Do not create more tasks and do not answer until comms has handled the notification.")
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -435,17 +438,35 @@ func runPlanDelegate(provider string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForPlanDelegateExecution(done <-chan error, notifySvc *NotifyService) error {
|
||||
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case err := <-done:
|
||||
tasks := taskSvc.count()
|
||||
notify := notifySvc.count()
|
||||
if err != nil {
|
||||
return fmt.Errorf("flow execute: %w", err)
|
||||
if isClientTimeout(err) && tasks == 3 && notify == 1 {
|
||||
fmt.Printf("\n\033[33mwarning:\033[0m flow execute returned after completed side effects: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: %w", tasks, notify, err)
|
||||
}
|
||||
if got := notifySvc.count(); got != 1 {
|
||||
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", got)
|
||||
if notify != 1 {
|
||||
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
|
||||
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
fmt.Print("\n\033[33mwarning:\033[0m flow completed before delegated notify; retrying the missing comms handoff once.\n")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
retryErr := recoverMissingNotify(ctx)
|
||||
cancel()
|
||||
if retryErr != nil {
|
||||
return fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, retryErr)
|
||||
}
|
||||
if notify = notifySvc.count(); notify != 1 {
|
||||
return fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
@@ -456,6 +477,11 @@ func waitForPlanDelegateExecution(done <-chan error, notifySvc *NotifyService) e
|
||||
}
|
||||
}
|
||||
|
||||
func isClientTimeout(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "request timeout") || strings.Contains(msg, "code=408") || strings.Contains(msg, "code\":408")
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
flag.Parse()
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -257,7 +258,7 @@ func TestPlanDelegateExecutionReportsDuplicateNotifyBeforeTimeout(t *testing.T)
|
||||
|
||||
done := make(chan error)
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- waitForPlanDelegateExecution(done, notifySvc) }()
|
||||
go func() { errCh <- waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil) }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
@@ -277,7 +278,7 @@ func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
err := waitForPlanDelegateExecution(done, notifySvc)
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want missing notify side-effect error")
|
||||
}
|
||||
@@ -286,6 +287,70 @@ func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionRecoversMissingNotifyOnce(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
var rsp AddResponse
|
||||
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
|
||||
t.Fatalf("Add(%q): %v", title, err)
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
recovered := false
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
var rsp SendResponse
|
||||
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want recovery success", err)
|
||||
}
|
||||
if !recovered {
|
||||
t.Fatal("missing notify recovery was not invoked")
|
||||
}
|
||||
if got := notifySvc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want 1 after recovery", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
var rsp AddResponse
|
||||
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
|
||||
t.Fatalf("Add(%q): %v", title, err)
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
var rsp SendResponse
|
||||
if err := notifySvc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, nil); err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want completed side effects to satisfy client timeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionRejectsClientTimeoutBeforeSideEffects(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), new(NotifyService), nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before side effects to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "tasks=0 notify=0") {
|
||||
t.Fatalf("error = %q, want side-effect counts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyServiceSendIsIdempotentForDuplicateDelivery(t *testing.T) {
|
||||
svc := new(NotifyService)
|
||||
for i := 0; i < 3; i++ {
|
||||
|
||||
@@ -51,6 +51,7 @@ Provider keys are read from `MICRO_AI_API_KEY` or the provider-specific variable
|
||||
| OpenAI | `OPENAI_API_KEY` |
|
||||
| Gemini | `GEMINI_API_KEY` |
|
||||
| Groq | `GROQ_API_KEY` |
|
||||
| MiniMax | `MINIMAX_API_KEY` |
|
||||
| Mistral | `MISTRAL_API_KEY` |
|
||||
| Together | `TOGETHER_API_KEY` |
|
||||
| AtlasCloud | `ATLASCLOUD_API_KEY` |
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/minimax"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
@@ -49,6 +50,7 @@ var providerEnv = map[string]string{
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"groq": "GROQ_API_KEY",
|
||||
"minimax": "MINIMAX_API_KEY",
|
||||
"mistral": "MISTRAL_API_KEY",
|
||||
"together": "TOGETHER_API_KEY",
|
||||
"atlascloud": "ATLASCLOUD_API_KEY",
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestValidateSelectionRejectsUnsafeHarnessName(t *testing.T) {
|
||||
|
||||
func TestDefaultProvidersTracksLiveProviderSet(t *testing.T) {
|
||||
got := defaultProviders()
|
||||
for _, want := range []string{"anthropic", "openai", "gemini", "groq", "mistral", "together", "atlascloud"} {
|
||||
for _, want := range []string{"anthropic", "openai", "gemini", "groq", "minimax", "mistral", "together", "atlascloud"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("defaultProviders() = %q, want %q", got, want)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
|
||||
t.Fatal("CapabilityRows returned no providers")
|
||||
}
|
||||
|
||||
var foundOpenAI bool
|
||||
var foundOpenAI, foundMiniMax bool
|
||||
for _, row := range rows {
|
||||
if row.Provider == "openai" {
|
||||
foundOpenAI = true
|
||||
@@ -62,10 +62,19 @@ func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
|
||||
t.Fatalf("openai capabilities = %#v, want model+image only", row.Capabilities)
|
||||
}
|
||||
}
|
||||
if row.Provider == "minimax" {
|
||||
foundMiniMax = true
|
||||
if !row.Model || !row.Stream || row.Image || row.Video {
|
||||
t.Fatalf("minimax capabilities = %#v, want model+stream only", row.Capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOpenAI {
|
||||
t.Fatalf("CapabilityRows = %#v, want openai row", rows)
|
||||
}
|
||||
if !foundMiniMax {
|
||||
t.Fatalf("CapabilityRows = %#v, want minimax row", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCapabilityMarkdown(t *testing.T) {
|
||||
|
||||
@@ -109,9 +109,10 @@ type SendResponse struct {
|
||||
}
|
||||
|
||||
type Notify struct {
|
||||
mu sync.Mutex
|
||||
sent int64
|
||||
seen map[string]struct{}
|
||||
mu sync.Mutex
|
||||
sent int64
|
||||
seen map[string]struct{}
|
||||
lastRejected *SendRequest
|
||||
}
|
||||
|
||||
// Send delivers a notification.
|
||||
@@ -122,11 +123,14 @@ func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) er
|
||||
if req != nil {
|
||||
to, message = req.To, req.Message
|
||||
}
|
||||
s.recordRejected(to, message)
|
||||
fmt.Printf(" \033[35m[notify]\033[0m 📨 ignored non-buyer notification to=%s %q\n", to, message)
|
||||
rsp.Sent = false
|
||||
return nil
|
||||
}
|
||||
|
||||
s.recordRejected("", "")
|
||||
|
||||
keys := notificationDedupeKeys(req)
|
||||
s.mu.Lock()
|
||||
if s.seen == nil {
|
||||
@@ -158,12 +162,34 @@ func isBuyerNotification(req *SendRequest) bool {
|
||||
return canonicalBuyerRecipient(req.To) != ""
|
||||
}
|
||||
|
||||
func (s *Notify) recordRejected(to, message string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if strings.TrimSpace(to) == "" && strings.TrimSpace(message) == "" {
|
||||
s.lastRejected = nil
|
||||
return
|
||||
}
|
||||
s.lastRejected = &SendRequest{To: to, Message: message}
|
||||
}
|
||||
|
||||
func (s *Notify) rejectedSummary() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.lastRejected == nil {
|
||||
return "no rejected notify call observed"
|
||||
}
|
||||
return fmt.Sprintf("last notify args to=%q message=%q", s.lastRejected.To, s.lastRejected.Message)
|
||||
}
|
||||
|
||||
func canonicalBuyerRecipient(to string) string {
|
||||
recipient := strings.ToLower(strings.TrimSpace(to))
|
||||
switch recipient {
|
||||
case "buyer", "buyer@acme.com":
|
||||
return "buyer@acme.com"
|
||||
}
|
||||
if strings.HasPrefix(recipient, "buyer-of-order-") && len(recipient) > len("buyer-of-order-") {
|
||||
return "buyer@acme.com"
|
||||
}
|
||||
for _, field := range strings.FieldsFunc(recipient, func(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r', ',', ';', ':', '/', '\\', '(', ')', '[', ']', '{', '}':
|
||||
@@ -230,7 +256,7 @@ func completeNotifyOnObservedSideEffect(ctx context.Context, in flow.State, ntf
|
||||
if dispatchErr != nil {
|
||||
return in, dispatchErr
|
||||
}
|
||||
return in, fmt.Errorf("concierge completed without notifying buyer: notify count stayed at %d", before)
|
||||
return in, fmt.Errorf("concierge completed without notifying buyer: notify count stayed at %d; expected recipient buyer@acme.com, buyer, or buyer-of-order-<id>; %s", before, ntf.rejectedSummary())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -84,8 +84,9 @@ func TestNotifyStepRejectsClaimedCompletionWithoutSideEffect(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("notify completion returned nil, want missing buyer notification error")
|
||||
}
|
||||
if got := err.Error(); got != "concierge completed without notifying buyer: notify count stayed at 0" {
|
||||
t.Fatalf("error = %q, want missing buyer notification error", got)
|
||||
want := `concierge completed without notifying buyer: notify count stayed at 0; expected recipient buyer@acme.com, buyer, or buyer-of-order-<id>; no rejected notify call observed`
|
||||
if got := err.Error(); got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +182,7 @@ func TestNotifyAcceptsOrderScopedBuyerRecipient(t *testing.T) {
|
||||
|
||||
var rsp SendResponse
|
||||
if err := ntf.Send(ctx, &SendRequest{
|
||||
To: "order-1 buyer",
|
||||
To: "buyer-of-order-1",
|
||||
Message: "order-1 confirmed",
|
||||
}, &rsp); err != nil {
|
||||
t.Fatalf("send order-scoped buyer notification: %v", err)
|
||||
@@ -206,3 +207,30 @@ func TestNotifyAcceptsOrderScopedBuyerRecipient(t *testing.T) {
|
||||
t.Fatalf("notifications sent after hyphenated non-buyer = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyStepReportsRejectedRecipientDiagnostics(t *testing.T) {
|
||||
ntf := new(Notify)
|
||||
var rsp SendResponse
|
||||
if err := ntf.Send(context.Background(), &SendRequest{
|
||||
To: "order-1",
|
||||
Message: "order-1 confirmed",
|
||||
}, &rsp); err != nil {
|
||||
t.Fatalf("send rejected notification: %v", err)
|
||||
}
|
||||
|
||||
_, err := completeNotifyOnObservedSideEffect(
|
||||
context.Background(),
|
||||
flow.State{Data: []byte(`claimed success`)},
|
||||
ntf,
|
||||
0,
|
||||
25*time.Millisecond,
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("notify completion returned nil, want diagnostics")
|
||||
}
|
||||
want := `concierge completed without notifying buyer: notify count stayed at 0; expected recipient buyer@acme.com, buyer, or buyer-of-order-<id>; last notify args to="order-1" message="order-1 confirmed"`
|
||||
if got := err.Error(); got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ The built-in providers currently register these capability interfaces:
|
||||
| `atlascloud` | Yes | Yes | Yes | Yes |
|
||||
| `gemini` | Yes | No | No | No |
|
||||
| `groq` | Yes | No | No | Yes |
|
||||
| `minimax` | Yes | No | No | Yes |
|
||||
| `mistral` | Yes | No | No | Yes |
|
||||
| `ollama` | Yes | No | No | Yes |
|
||||
| `openai` | Yes | Yes | No | Yes |
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/minimax"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/ollama"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
|
||||
+7
-7
@@ -94,7 +94,7 @@ func fileTest(s Store, t *testing.T) {
|
||||
{
|
||||
Key: "foobar",
|
||||
Value: []byte("foobarfoobar"),
|
||||
Expiry: time.Millisecond * 100,
|
||||
Expiry: time.Second, // wide window: CI I/O under -race can exceed a 100ms expiry before the read below
|
||||
},
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ func fileTest(s Store, t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// wait for the expiry
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
// wait for the expiry (must exceed the 1s Expiry above, with margin for slow CI)
|
||||
time.Sleep(time.Second * 2)
|
||||
|
||||
if results, err := s.Read("foo", ReadPrefix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
@@ -199,20 +199,20 @@ func fileTest(s Store, t *testing.T) {
|
||||
if err := s.Write(&Record{
|
||||
Key: "foofoobarbar",
|
||||
Value: []byte("something"),
|
||||
}, WriteTTL(time.Millisecond*100)); err != nil {
|
||||
}, WriteTTL(time.Second)); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := s.Write(&Record{
|
||||
Key: "foofoo",
|
||||
Value: []byte("something"),
|
||||
}, WriteExpiry(time.Now().Add(time.Millisecond*100))); err != nil {
|
||||
}, WriteExpiry(time.Now().Add(time.Second))); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := s.Write(&Record{
|
||||
Key: "barbar",
|
||||
Value: []byte("something"),
|
||||
// TTL has higher precedence than expiry
|
||||
}, WriteExpiry(time.Now().Add(time.Hour)), WriteTTL(time.Millisecond*100)); err != nil {
|
||||
}, WriteExpiry(time.Now().Add(time.Hour)), WriteTTL(time.Second)); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ func fileTest(s Store, t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
time.Sleep(time.Second * 2) // exceed the 1s TTL/expiry above so everything has expired
|
||||
|
||||
if results, err := s.List(); err != nil {
|
||||
t.Errorf("List failed: %s", err)
|
||||
|
||||
Reference in New Issue
Block a user