Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ c := New(
```

### Error Hierarchy
`errors.go` defines `APIError` as the base struct with `StatusCode`, `Code`, `Message`, `Details`. Each HTTP status gets a wrapper type (`NotFoundError`, `RateLimitError`, etc.) that embeds `APIError`. All error types implement `Error()` and `Unwrap()` for `errors.Is`/`errors.As` compatibility:
`errors.go` defines `APIError` as the base struct with `StatusCode`, `Code`, `Message`, `Details`. Each HTTP status gets a wrapper type (`NotFoundError`, `RateLimitError`, etc.) that embeds `APIError`. Subtypes inherit `Error()` via embedding (only `RateLimitError` overrides it to append retry info) and implement `Unwrap()` for `errors.Is`/`errors.As` compatibility:
```go
var notFound *NotFoundError
if errors.As(err, &notFound) {
Expand Down Expand Up @@ -145,7 +145,7 @@ if !errors.As(err, &notFound) { t.Error("expected NotFoundError") }
- **Do not change**: `APIError.Error()` format string — tests assert exact string output
- **Do not change**: JSON struct tags — they match the daemon's API contract
- **Do not change**: `Unwrap()` implementations — `errors.As` depends on them for type matching
- **Safe to extend**: add new error types by creating a struct embedding `APIError`, adding to `parseAPIError` switch, and implementing `Error()` + `Unwrap()`
- **Safe to extend**: add new error types by creating a struct embedding `APIError` (which promotes `Error()`), adding to `parseAPIError` switch, and implementing `Unwrap()`
- **Safe to extend**: add new client methods by following the `get()`/`post()` delegation pattern
- **When adding streaming endpoints**: follow `ChatStream` pattern — set `Accept: text/event-stream`, check status before wrapping in `newStreamReader`
- **Concurrency**: any new mutable state on `Agent` must be protected by `a.mu`
15 changes: 15 additions & 0 deletions agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@

// Agent wraps a Client with declarative configuration, providing a
// simplified interface for conversational AI interactions.
//
// Concurrency: Agent is safe for concurrent use. The session ID is read and
// updated under an internal mutex. Each Chat or ChatStream call captures the
// session ID at the moment the request is built; a stream returned by
// ChatStream continues to use the session ID captured at call time even if a
// concurrent Chat call establishes a new session while the stream is being
// consumed.
type Agent struct {
client *Client
config AgentConfig
Expand All @@ -50,7 +57,7 @@
}

// NewAgent creates a new Agent with the given client and configuration.
func NewAgent(client *Client, config AgentConfig) *Agent {

Check failure on line 60 in agent.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: NewAgent
a := &Agent{
client: client,
config: config,
Expand All @@ -63,7 +70,7 @@

// Chat sends a message and returns the complete response.
// If the agent has tools configured, it automatically uses ChatWithTools.
func (a *Agent) Chat(ctx context.Context, message string) (*ChatResponse, error) {

Check failure on line 73 in agent.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: Agent.Chat
a.mu.Lock()
defer a.mu.Unlock()

Expand Down Expand Up @@ -94,7 +101,15 @@
// ChatStream sends a message and returns a streaming response reader.
// Note: streaming with tools is not automatically looped; use Chat for
// full tool loop support.
//
// The session ID is captured under the agent's lock when the request is
// built, so the entire stream lifecycle uses that snapshot: a concurrent
// Chat call that mutates the agent's session ID does not affect an
// in-flight stream.
func (a *Agent) ChatStream(ctx context.Context, message string) (*StreamReader, error) {

Check failure on line 109 in agent.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: Agent.ChatStream
// Capture the session ID into the request under the lock. req holds the
// captured value by copy, so the stream is immune to later mutations of
// a.sessionID by concurrent Chat calls.
a.mu.Lock()
req := a.buildRequest(message)
a.mu.Unlock()
Expand All @@ -102,14 +117,14 @@
}

// SessionID returns the current session ID, if established.
func (a *Agent) SessionID() string {

Check failure on line 120 in agent.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: Agent.SessionID
a.mu.Lock()
defer a.mu.Unlock()
return a.sessionID
}

// buildRequest constructs a ChatRequest from the agent config and message.
func (a *Agent) buildRequest(message string) ChatRequest {

Check failure on line 127 in agent.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: Agent.buildRequest
req := ChatRequest{
Prompt: message,
Model: a.config.Model,
Expand All @@ -124,7 +139,7 @@
}

// updateSession stores the session ID from the response for continuity.
func (a *Agent) updateSession(resp *ChatResponse) {

Check failure on line 142 in agent.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: Agent.updateSession
if resp != nil && resp.SessionID != "" {
a.sessionID = resp.SessionID
}
Expand Down
62 changes: 62 additions & 0 deletions agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
)
Expand Down Expand Up @@ -197,6 +198,67 @@ func TestAgent_ChatStream(t *testing.T) {
}
}

// TestAgent_ConcurrentChatAndStream runs Chat and ChatStream concurrently
// (run with -race) to verify that ChatStream's session ID snapshot is not
// affected by concurrent Chat calls mutating a.sessionID, and that no data
// race exists between request building and session updates.
func TestAgent_ConcurrentChatAndStream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "text/event-stream" {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
w.Write([]byte("data: chunk\n\n"))
w.Write([]byte("event: done\ndata: {}\n\n"))
return
}
json.NewEncoder(w).Encode(ChatResponse{
SessionID: "race-sess",
Response: "ok",
})
}))
defer srv.Close()

c := New(WithBaseURL(srv.URL))
agent := NewAgent(c, AgentConfig{Model: "test"})

const iterations = 10
var wg sync.WaitGroup
errs := make(chan error, iterations*2)

for i := 0; i < iterations; i++ {
wg.Add(2)
go func() {
defer wg.Done()
if _, err := agent.Chat(context.Background(), "hello"); err != nil {
errs <- err
}
}()
go func() {
defer wg.Done()
stream, err := agent.ChatStream(context.Background(), "stream hello")
if err != nil {
errs <- err
return
}
defer stream.Close()
// Consume the whole stream while Chat calls mutate sessionID.
if _, err := stream.CollectText(context.Background()); err != nil {
errs <- err
}
}()
}
wg.Wait()
close(errs)

for err := range errs {
t.Errorf("concurrent Chat/ChatStream error: %v", err)
}

if got := agent.SessionID(); got != "race-sess" {
t.Errorf("SessionID = %q, want %q", got, "race-sess")
}
}

func TestNewAgent_Defaults(t *testing.T) {
c := New()
agent := NewAgent(c, AgentConfig{})
Expand Down
13 changes: 11 additions & 2 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,26 @@
type ClientOption func(*Client)

// WithBaseURL sets the daemon base URL (default: http://127.0.0.1:4590).
func WithBaseURL(u string) ClientOption {

Check failure on line 29 in client.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: WithBaseURL
return func(c *Client) { c.baseURL = strings.TrimRight(u, "/") }
}

// WithHTTPClient sets a custom http.Client.
func WithHTTPClient(hc *http.Client) ClientOption {

Check failure on line 34 in client.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: WithHTTPClient
return func(c *Client) { c.httpClient = hc }
}

// WithAPIKey sets an API key for authentication. The key is sent as
// an Authorization: Bearer header on every request.
func WithAPIKey(key string) ClientOption {

Check failure on line 40 in client.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: WithAPIKey
return func(c *Client) { c.apiKey = key }
}

// New creates a new hawk SDK client.
//
// Note: the client performs no retries by default. Pass
// WithRetry(DefaultRetryConfig()) for production use to enable automatic
// retries with exponential backoff on transient failures.
func New(opts ...ClientOption) *Client {
c := &Client{
baseURL: defaultBaseURL,
Expand Down Expand Up @@ -72,7 +76,7 @@
}

// ChatStream sends a prompt and streams the response via SSE.
func (c *Client) ChatStream(ctx context.Context, req ChatRequest) (*StreamReader, error) {

Check failure on line 79 in client.go

View workflow job for this annotation

GitHub Actions / deadcode

unreachable func: Client.ChatStream
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("hawk-sdk: marshal request: %w", err)
Expand Down Expand Up @@ -144,7 +148,10 @@
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
// The daemon returns 204 No Content on delete, but older daemon versions
// and intermediary proxies may respond with 200 OK instead. Accepting any
// 2xx keeps this defensive and consistent with post()'s success handling.
if resp.StatusCode/100 != 2 {
return parseAPIError(resp)
}
return nil
Expand Down Expand Up @@ -226,7 +233,9 @@
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
// Accept any 2xx status: creation endpoints may return 201 Created
// and future endpoints may use other success codes.
if resp.StatusCode/100 != 2 {
return parseAPIError(resp)
}

Expand Down
10 changes: 5 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,16 @@ c := hawksdk.New(
health, err := c.Health(ctx)

// 💬 Non-streaming chat
resp, err := c.Chat(ctx, hawksdk.ChatRequest{Message: "list files"})
resp, err := c.Chat(ctx, hawksdk.ChatRequest{Prompt: "list files"})

// 📡 Streaming chat
stream, err := c.ChatStream(ctx, hawksdk.ChatRequest{Message: "explain this code"})
stream, err := c.ChatStream(ctx, hawksdk.ChatRequest{Prompt: "explain this code"})
defer stream.Close()
for { ev, err := stream.Next(); if err != nil { break }; fmt.Print(ev.Data) }

// 📋 Sessions
sessions, _ := c.Sessions(ctx, hawksdk.ListOptions{Limit: 10})
msgs, _ := c.Messages(ctx, sessionID, hawksdk.ListOptions{})
sessions, _ := c.Sessions(ctx, &hawksdk.ListOptions{Limit: 10})
msgs, _ := c.Messages(ctx, sessionID, nil)
_ = c.DeleteSession(ctx, sessionID)

// 📊 Stats
Expand All @@ -73,7 +73,7 @@ stats, _ := c.Stats(ctx)
## 🤖 Agent (Higher-Level)

```go
agent := hawksdk.NewAgent(c, hawksdk.AgentConfig{SystemPrompt: "You are a Go expert"})
agent := hawksdk.NewAgent(c, hawksdk.AgentConfig{Model: "claude-sonnet-4-5", MaxRounds: 5})
resp, _ := agent.Chat(ctx, "refactor this function")
// Subsequent calls automatically continue the same session
```
Expand Down
18 changes: 0 additions & 18 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ type BadRequestError struct {
APIError
}

// Error implements the error interface.
func (e *BadRequestError) Error() string { return e.APIError.Error() }

// Unwrap allows errors.Is/As to match the underlying APIError.
func (e *BadRequestError) Unwrap() error { return &e.APIError }

Expand All @@ -46,9 +43,6 @@ type AuthenticationError struct {
APIError
}

// Error implements the error interface.
func (e *AuthenticationError) Error() string { return e.APIError.Error() }

// Unwrap allows errors.Is/As to match the underlying APIError.
func (e *AuthenticationError) Unwrap() error { return &e.APIError }

Expand All @@ -57,9 +51,6 @@ type ForbiddenError struct {
APIError
}

// Error implements the error interface.
func (e *ForbiddenError) Error() string { return e.APIError.Error() }

// Unwrap allows errors.Is/As to match the underlying APIError.
func (e *ForbiddenError) Unwrap() error { return &e.APIError }

Expand All @@ -68,9 +59,6 @@ type NotFoundError struct {
APIError
}

// Error implements the error interface.
func (e *NotFoundError) Error() string { return e.APIError.Error() }

// Unwrap allows errors.Is/As to match the underlying APIError.
func (e *NotFoundError) Unwrap() error { return &e.APIError }

Expand Down Expand Up @@ -98,9 +86,6 @@ type InternalServerError struct {
APIError
}

// Error implements the error interface.
func (e *InternalServerError) Error() string { return e.APIError.Error() }

// Unwrap allows errors.Is/As to match the underlying APIError.
func (e *InternalServerError) Unwrap() error { return &e.APIError }

Expand All @@ -109,9 +94,6 @@ type ServiceUnavailableError struct {
APIError
}

// Error implements the error interface.
func (e *ServiceUnavailableError) Error() string { return e.APIError.Error() }

// Unwrap allows errors.Is/As to match the underlying APIError.
func (e *ServiceUnavailableError) Unwrap() error { return &e.APIError }

Expand Down
23 changes: 23 additions & 0 deletions sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,29 @@ func TestCreateSession(t *testing.T) {
}
}

// TestCreateSession201 verifies that post() accepts any 2xx status, not
// just 200 OK — creation endpoints commonly return 201 Created.
func TestCreateSession201(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(SessionSummary{
ID: "created-sess",
CWD: "/tmp",
})
}))
defer srv.Close()

c := New(WithBaseURL(srv.URL))
resp, err := c.CreateSession(context.Background(), CreateSessionRequest{Name: "n"})
if err != nil {
t.Fatalf("CreateSession() with 201 response error: %v", err)
}
if resp.ID != "created-sess" {
t.Errorf("ID = %q, want %q", resp.ID, "created-sess")
}
}

func TestCreateSessionEmptyBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req CreateSessionRequest
Expand Down
14 changes: 14 additions & 0 deletions stream_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,22 @@ type ToolCallDelta struct {

// CollectText consumes the entire stream and returns the concatenated text content.
// It blocks until the stream ends or the context is cancelled.
//
// When the returned error is non-nil, the returned string may be a partial
// result: it contains all text collected up to the point the error occurred,
// which callers may use or discard as appropriate. The error returned is the
// first error encountered while consuming the stream — if the stream emitted
// an "error" event before a later read failure, the "error" event wins.
func (sr *StreamReader) CollectText(ctx context.Context) (string, error) {
var sb strings.Builder
var firstErr error

for {
select {
case <-ctx.Done():
if firstErr != nil {
return sb.String(), firstErr
}
return sb.String(), ctx.Err()
default:
}
Expand All @@ -68,6 +77,11 @@ func (sr *StreamReader) CollectText(ctx context.Context) (string, error) {
return sb.String(), firstErr
}
if err != nil {
// Preserve first-error semantics: an "error" event seen earlier
// takes precedence over a subsequent read failure.
if firstErr != nil {
return sb.String(), firstErr
}
return sb.String(), err
}

Expand Down
Loading
Loading