mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
Every workflow in this repo was deleted and nothing replaced them, so the sole producer of ghcr.io/hanzoai/agent no longer existed anywhere and there was no CI at all. That is silent staleness: no failing run, just an artifact that quietly stops being rebuilt. .github/workflows now holds sync.yml and nothing else, copied verbatim from universe infra/ci/sync.yml. It replaces a mirror-nudge file that POSTed /api/v1/repos/.../mirror-sync and described git.hanzo.ai as a downstream pull mirror; the forge is canonical and refs are pushed to it. Without it a .hanzo/workflows file has nothing to trigger it. deploy.yml restores docker-publish.yml. Two things could not come across as-is: - Its body was one `uses: hanzoai/.github/.github/workflows/docker-build.yml@main`. A private cross-repo reusable workflow does not resolve on the forge, so the amd64 build/push is vendored inline, as hanzoai/bot already does. - It built `context: control-plane`, and no control-plane directory exists on main; the orchestrator was hoisted to the root module in v0.1.1 and the directory survives only on the unmerged consolidate/held-work branch. Restoring that context restores a build that cannot run. It builds Dockerfile.sdk — the one Dockerfile on main whose context exists, previously referenced by nothing despite sdk-* tags being live on the image — and tags it sdk-* so the two Dockerfiles cannot collide in one tag namespace. The tag rule is shell rather than docker/metadata-action, so it is two readable lines with no action semantics to guess at, and it was tested standalone: v* -> :sdk-X.Y.Z + :sdk-latest, main -> :sdk-sha-<sha7> + :sdk-latest. ci.yml replaces tests.yml and test.yml, which checked the same things twice, the second with `|| true` on most steps so it could not fail. It gates what is verifiable on main today — gofmt/build/vet/test for the Go module, and the locked install plus `import agents` for Python, which is what Dockerfile.sdk's builder stage and HEALTHCHECK do. It deliberately does not run ruff, mypy or pytest: `make sync` cannot resolve (requires-python >=3.9, a transitive numpy needs >=3.10), ruff reports 2337 errors, and pytest INTERNALERRORs at collection because a test module sys.exit(1)s at import. tests.yml ran `make sync` first in all five jobs, so none of them could have gone green. Those four are written down in LLM.md as work rather than shipped as a permanently red check, which gets read as broken CI instead of broken code. gofmt over eight files so that gate is real instead of omitted; build, vet and test re-run green afterwards. Alignment whitespace only. Not restored, with reasons in LLM.md: docs.yml (mkdocs gh-deploy is GitHub Pages, never published anything, and Pages is not how we serve sites), publish.yml (pointed at the PyPI package hanzoai/python-sdk owns; deliberately removed earlier), issues.yml (upstream's stale-issue bot). LLM.md documents the chain, records the four broken Python checks, and states the open question this repo actually has: nothing in hanzoai/universe references ghcr.io/hanzoai/agent, so the image has no consumer and no App CR. .gitignore ignored .hanzo/ under an "# IDE" heading, which silently dropped both new workflows from the commit — the same silent-staleness shape this is meant to remove, one level down. Narrowed to .hanzo/* with .hanzo/workflows/ re-included. Co-authored-by: Hanzo Dev <[email protected]>
376 lines
9.0 KiB
Go
376 lines
9.0 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNote_Basic(t *testing.T) {
|
|
var receivedPayload notePayload
|
|
var receivedHeaders http.Header
|
|
var mu sync.Mutex
|
|
requestReceived := make(chan struct{})
|
|
|
|
// Create a test server
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Parse the payload
|
|
body, _ := io.ReadAll(r.Body)
|
|
json.Unmarshal(body, &receivedPayload)
|
|
receivedHeaders = r.Header.Clone()
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
close(requestReceived)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := Config{
|
|
NodeID: "test-node",
|
|
Version: "1.0.0",
|
|
HanzoAgentsURL: server.URL + "/api/v1", // Will be converted to /api/ui/v1
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
|
|
agent, err := New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
ctx := contextWithExecution(context.Background(), ExecutionContext{
|
|
RunID: "run-123",
|
|
ExecutionID: "exec-456",
|
|
SessionID: "session-789",
|
|
ActorID: "actor-abc",
|
|
WorkflowID: "workflow-xyz",
|
|
})
|
|
|
|
agent.Note(ctx, "Test message", "tag1", "tag2")
|
|
|
|
// Wait for the note to be sent
|
|
select {
|
|
case <-requestReceived:
|
|
// Success
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Timeout waiting for note request")
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Verify payload
|
|
assert.Equal(t, "Test message", receivedPayload.Message)
|
|
assert.Equal(t, []string{"tag1", "tag2"}, receivedPayload.Tags)
|
|
assert.Equal(t, "test-node", receivedPayload.AgentNodeID)
|
|
assert.Greater(t, receivedPayload.Timestamp, float64(0))
|
|
|
|
// Verify headers
|
|
assert.Equal(t, "run-123", receivedHeaders.Get("X-Run-ID"))
|
|
assert.Equal(t, "exec-456", receivedHeaders.Get("X-Execution-ID"))
|
|
assert.Equal(t, "session-789", receivedHeaders.Get("X-Session-ID"))
|
|
assert.Equal(t, "actor-abc", receivedHeaders.Get("X-Actor-ID"))
|
|
assert.Equal(t, "workflow-xyz", receivedHeaders.Get("X-Workflow-ID"))
|
|
assert.Equal(t, "test-node", receivedHeaders.Get("X-Agent-Node-ID"))
|
|
}
|
|
|
|
func TestNotef_Formatted(t *testing.T) {
|
|
var receivedPayload notePayload
|
|
requestReceived := make(chan struct{})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
json.Unmarshal(body, &receivedPayload)
|
|
w.WriteHeader(http.StatusOK)
|
|
close(requestReceived)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := Config{
|
|
NodeID: "test-node",
|
|
Version: "1.0.0",
|
|
HanzoAgentsURL: server.URL + "/api/v1",
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
|
|
agent, err := New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
ctx := contextWithExecution(context.Background(), ExecutionContext{
|
|
RunID: "run-123",
|
|
})
|
|
|
|
agent.Notef(ctx, "Processing %d items with status %s", 42, "active")
|
|
|
|
select {
|
|
case <-requestReceived:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Timeout waiting for note request")
|
|
}
|
|
|
|
assert.Equal(t, "Processing 42 items with status active", receivedPayload.Message)
|
|
}
|
|
|
|
func TestNote_NoTags(t *testing.T) {
|
|
var receivedPayload notePayload
|
|
requestReceived := make(chan struct{})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
json.Unmarshal(body, &receivedPayload)
|
|
w.WriteHeader(http.StatusOK)
|
|
close(requestReceived)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := Config{
|
|
NodeID: "test-node",
|
|
Version: "1.0.0",
|
|
HanzoAgentsURL: server.URL + "/api/v1",
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
|
|
agent, err := New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
ctx := contextWithExecution(context.Background(), ExecutionContext{
|
|
RunID: "run-123",
|
|
})
|
|
|
|
// Call Note without tags
|
|
agent.Note(ctx, "No tags message")
|
|
|
|
select {
|
|
case <-requestReceived:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Timeout waiting for note request")
|
|
}
|
|
|
|
assert.Equal(t, "No tags message", receivedPayload.Message)
|
|
assert.Equal(t, []string{}, receivedPayload.Tags)
|
|
}
|
|
|
|
func TestNote_NoHanzoAgentsURL(t *testing.T) {
|
|
// Agent without HanzoAgentsURL should not send notes
|
|
cfg := Config{
|
|
NodeID: "test-node",
|
|
Version: "1.0.0",
|
|
Logger: log.New(io.Discard, "", 0),
|
|
// No HanzoAgentsURL
|
|
}
|
|
|
|
agent, err := New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
ctx := contextWithExecution(context.Background(), ExecutionContext{
|
|
RunID: "run-123",
|
|
})
|
|
|
|
// This should not panic or block
|
|
agent.Note(ctx, "This note goes nowhere")
|
|
|
|
// Give it a moment to ensure no panic
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
func TestNote_ServerError(t *testing.T) {
|
|
// Server that returns an error
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := Config{
|
|
NodeID: "test-node",
|
|
Version: "1.0.0",
|
|
HanzoAgentsURL: server.URL + "/api/v1",
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
|
|
agent, err := New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
ctx := contextWithExecution(context.Background(), ExecutionContext{
|
|
RunID: "run-123",
|
|
})
|
|
|
|
// Should not panic even with server error
|
|
agent.Note(ctx, "Test message")
|
|
|
|
// Give it time to complete
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
|
|
func TestNote_URLConversion(t *testing.T) {
|
|
var requestPath string
|
|
requestReceived := make(chan struct{})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestPath = r.URL.Path
|
|
w.WriteHeader(http.StatusOK)
|
|
close(requestReceived)
|
|
}))
|
|
defer server.Close()
|
|
|
|
tests := []struct {
|
|
name string
|
|
controlPlaneURL string
|
|
expectedPath string
|
|
}{
|
|
{
|
|
name: "Standard /api/v1 URL",
|
|
controlPlaneURL: server.URL + "/api/v1",
|
|
expectedPath: "/api/ui/v1/executions/note",
|
|
},
|
|
{
|
|
name: "URL without /api/v1",
|
|
controlPlaneURL: server.URL,
|
|
expectedPath: "/api/ui/v1/executions/note",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
requestReceived = make(chan struct{})
|
|
requestPath = ""
|
|
|
|
cfg := Config{
|
|
NodeID: "test-node",
|
|
Version: "1.0.0",
|
|
HanzoAgentsURL: tt.controlPlaneURL,
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
|
|
agent, err := New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
ctx := contextWithExecution(context.Background(), ExecutionContext{
|
|
RunID: "run-123",
|
|
})
|
|
|
|
agent.Note(ctx, "Test")
|
|
|
|
select {
|
|
case <-requestReceived:
|
|
assert.Equal(t, tt.expectedPath, requestPath)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Timeout waiting for note request")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNote_WithToken(t *testing.T) {
|
|
var receivedAuthHeader string
|
|
requestReceived := make(chan struct{})
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
receivedAuthHeader = r.Header.Get("Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
close(requestReceived)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := Config{
|
|
NodeID: "test-node",
|
|
Version: "1.0.0",
|
|
HanzoAgentsURL: server.URL + "/api/v1",
|
|
Token: "test-token-123",
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
|
|
agent, err := New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
ctx := contextWithExecution(context.Background(), ExecutionContext{
|
|
RunID: "run-123",
|
|
})
|
|
|
|
agent.Note(ctx, "Test message")
|
|
|
|
select {
|
|
case <-requestReceived:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Timeout waiting for note request")
|
|
}
|
|
|
|
assert.Equal(t, "Bearer test-token-123", receivedAuthHeader)
|
|
}
|
|
|
|
func TestNote_FireAndForget(t *testing.T) {
|
|
// Test that Note doesn't block even if server is slow
|
|
slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(5 * time.Second) // Slow response
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer slowServer.Close()
|
|
|
|
cfg := Config{
|
|
NodeID: "test-node",
|
|
Version: "1.0.0",
|
|
HanzoAgentsURL: slowServer.URL + "/api/v1",
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
|
|
agent, err := New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
ctx := contextWithExecution(context.Background(), ExecutionContext{
|
|
RunID: "run-123",
|
|
})
|
|
|
|
start := time.Now()
|
|
agent.Note(ctx, "Test message")
|
|
elapsed := time.Since(start)
|
|
|
|
// Note should return immediately (< 100ms), not wait for server
|
|
assert.Less(t, elapsed, 100*time.Millisecond, "Note should not block")
|
|
}
|
|
|
|
func TestNote_MultipleNotes(t *testing.T) {
|
|
var mu sync.Mutex
|
|
noteCount := 0
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
noteCount++
|
|
mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := Config{
|
|
NodeID: "test-node",
|
|
Version: "1.0.0",
|
|
HanzoAgentsURL: server.URL + "/api/v1",
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
|
|
agent, err := New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
ctx := contextWithExecution(context.Background(), ExecutionContext{
|
|
RunID: "run-123",
|
|
})
|
|
|
|
// Send multiple notes
|
|
for i := 0; i < 5; i++ {
|
|
agent.Notef(ctx, "Note %d", i)
|
|
}
|
|
|
|
// Wait for all notes to be sent
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
assert.Equal(t, 5, noteCount)
|
|
}
|