First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// End-to-end through a real norma provider: the Capture rides the context into
|
||||
// norma, survives its internal request building, and comes back holding the
|
||||
// exact bytes buildBody() put on the wire. This is the load-bearing assumption
|
||||
// of the whole feature — norma must propagate the caller's context down to
|
||||
// http.NewRequestWithContext.
|
||||
func TestCapturePropagatesThroughNormaProvider(t *testing.T) {
|
||||
sse := strings.Join([]string{
|
||||
`event: message_start`,
|
||||
`data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":11,"output_tokens":1}}}`,
|
||||
``,
|
||||
`event: content_block_delta`,
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`,
|
||||
``,
|
||||
`event: message_delta`,
|
||||
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}`,
|
||||
``,
|
||||
`event: message_stop`,
|
||||
`data: {"type":"message_stop"}`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
// Record exactly what the server receives, so the capture can be compared
|
||||
// against it byte for byte rather than merely spot-checked for fields.
|
||||
var gotPath, serverSaw string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("server read body: %v", err)
|
||||
}
|
||||
serverSaw = string(b)
|
||||
w.Header().Set("content-type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, sse)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := Config{
|
||||
Format: llm.FormatAnthropic,
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "test-key",
|
||||
Model: "claude-test",
|
||||
}
|
||||
prov, err := cfg.NewProvider()
|
||||
if err != nil {
|
||||
t.Fatalf("NewProvider: %v", err)
|
||||
}
|
||||
|
||||
ctx, capt := llmrec.NewCapture(context.Background())
|
||||
req := llm.CompletionRequest{
|
||||
System: []string{"you are a scanner"},
|
||||
Messages: []llm.Message{{Role: "user", Content: []llm.ContentBlock{{Type: "text", Text: "go"}}}},
|
||||
Tools: []llm.ToolSchema{{
|
||||
Name: "bash",
|
||||
Description: "run a shell command",
|
||||
InputSchema: map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}},
|
||||
}},
|
||||
MaxTokens: 1024,
|
||||
}
|
||||
|
||||
var text strings.Builder
|
||||
for ev, err := range prov.Stream(ctx, req) {
|
||||
if err != nil {
|
||||
t.Fatalf("stream: %v", err)
|
||||
}
|
||||
if ev.Type == llm.SETextDelta {
|
||||
text.WriteString(ev.Text)
|
||||
}
|
||||
}
|
||||
if text.String() != "hello" {
|
||||
t.Fatalf("stream text=%q, capture interfered with delivery", text.String())
|
||||
}
|
||||
if gotPath != "/v1/messages" {
|
||||
t.Fatalf("path=%q", gotPath)
|
||||
}
|
||||
|
||||
// The raw request must be what norma actually sent, not the recorder's
|
||||
// re-serialization — which is exactly why it carries fields the normalized
|
||||
// view drops.
|
||||
raw := capt.RawRequest()
|
||||
if raw == "" {
|
||||
t.Fatal("no raw request captured — context did not reach the transport")
|
||||
}
|
||||
// The load-bearing claim: what we stored equals, byte for byte, what the
|
||||
// server received — not merely "has the right fields".
|
||||
if raw != serverSaw {
|
||||
t.Fatalf("captured request != what the server received:\n got: %q\nsaw: %q", raw, serverSaw)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
||||
t.Fatalf("raw request is not valid JSON: %v\n%s", err, raw)
|
||||
}
|
||||
if body["model"] != "claude-test" {
|
||||
t.Errorf("model=%v want claude-test (absent from CompletionRequest)", body["model"])
|
||||
}
|
||||
if body["stream"] != true {
|
||||
t.Errorf("stream=%v want true", body["stream"])
|
||||
}
|
||||
// The full tool schema is the headline gain: the normalized view keeps names only.
|
||||
tools, _ := body["tools"].([]any)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("tools=%v", body["tools"])
|
||||
}
|
||||
tool, _ := tools[0].(map[string]any)
|
||||
if tool["description"] != "run a shell command" {
|
||||
t.Errorf("tool description missing: %v", tool)
|
||||
}
|
||||
if tool["input_schema"] == nil {
|
||||
t.Errorf("tool input_schema missing: %v", tool)
|
||||
}
|
||||
|
||||
// And the response is the untouched SSE frames, including the events the
|
||||
// recorder never turns into stored output.
|
||||
if capt.RawResponse() != sse {
|
||||
t.Errorf("RawResponse mismatch:\n got: %q\nwant: %q", capt.RawResponse(), sse)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user