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
57 changes: 57 additions & 0 deletions iterator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package hawksdk

import (
"context"
"iter"
)

// defaultIterPageSize is used when the caller's ListOptions has no Limit set.
const defaultIterPageSize = 50

// AllMessages returns an iterator over every message in a session, fetching
// additional pages from the daemon transparently as the caller ranges past
// the end of each page. Iteration stops when the daemon reports no more
// pages (HasMore == false) or when a request fails, in which case the error
// is yielded and iteration stops.
//
// opts may be nil; a copy is used internally so the caller's ListOptions is
// never mutated. If opts.Limit is unset (<= 0), defaultIterPageSize is used.
//
// for msg, err := range hawksdk.AllMessages(ctx, client, sessionID, nil) {
// if err != nil {
// // handle err, stop iterating
// break
// }
// // use msg
// }
func AllMessages(ctx context.Context, c *Client, sessionID string, opts *ListOptions) iter.Seq2[Message, error] {
return func(yield func(Message, error) bool) {
limit := defaultIterPageSize
offset := 0
if opts != nil {
if opts.Limit > 0 {
limit = opts.Limit
}
offset = opts.Offset
}

for {
page, err := c.Messages(ctx, sessionID, &ListOptions{Offset: offset, Limit: limit})
if err != nil {
yield(Message{}, err)
return
}

for _, msg := range page.Data {
if !yield(msg, nil) {
return
}
}

if !page.HasMore {
return
}
offset += limit
}
}
}
97 changes: 97 additions & 0 deletions iterator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package hawksdk

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)

func TestAllMessages(t *testing.T) {
// Three pages of 2 messages each, offset advancing by limit=2.
total := 6
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit != 2 {
t.Errorf("limit = %d, want 2", limit)
}
var data []Message
for i := offset; i < offset+limit && i < total; i++ {
data = append(data, Message{Role: "user", Content: fmt.Sprintf("msg-%d", i)})
}
json.NewEncoder(w).Encode(PaginatedResponse[Message]{
Data: data,
Total: total,
Offset: offset,
Limit: limit,
HasMore: offset+limit < total,
})
}))
defer srv.Close()

c := New(WithBaseURL(srv.URL))

var got []string
for msg, err := range AllMessages(context.Background(), c, "sess-1", &ListOptions{Limit: 2}) {
if err != nil {
t.Fatalf("AllMessages() error: %v", err)
}
got = append(got, msg.Content)
}

if len(got) != total {
t.Fatalf("got %d messages, want %d: %v", len(got), total, got)
}
for i, content := range got {
want := fmt.Sprintf("msg-%d", i)
if content != want {
t.Errorf("got[%d] = %q, want %q", i, content, want)
}
}
}

func TestAllMessagesStopsEarly(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(PaginatedResponse[Message]{
Data: []Message{{Content: "a"}, {Content: "b"}},
HasMore: true,
})
}))
defer srv.Close()

c := New(WithBaseURL(srv.URL))

count := 0
for range AllMessages(context.Background(), c, "sess-1", &ListOptions{Limit: 2}) {
count++
if count == 1 {
break
}
}
if count != 1 {
t.Errorf("count = %d, want 1 (should stop on caller break)", count)
}
}

func TestAllMessagesPropagatesError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(ErrorResponse{Error: "boom"})
}))
defer srv.Close()

c := New(WithBaseURL(srv.URL))

var gotErr error
for _, err := range AllMessages(context.Background(), c, "sess-1", nil) {
gotErr = err
break
}
if gotErr == nil {
t.Fatal("expected an error, got nil")
}
}
24 changes: 24 additions & 0 deletions parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package hawksdk

import (
"encoding/json"
"fmt"
)

// ParseInto decodes a ChatResponse's Response text as JSON into a caller-
// supplied type T. This is a best-effort client-side convenience: the daemon
// API has no schema-negotiation mechanism (ChatRequest carries no response-
// format field), so the model isn't constrained server-side to emit T-shaped
// JSON. Callers that need that guarantee should instruct the model to emit
// JSON via the prompt and treat ParseInto's error as "the model didn't
// comply," not as a validation failure of the daemon.
func ParseInto[T any](resp *ChatResponse) (T, error) {
var out T
if resp == nil {
return out, fmt.Errorf("hawk-sdk: ParseInto: nil response")
}
if err := json.Unmarshal([]byte(resp.Response), &out); err != nil {
return out, fmt.Errorf("hawk-sdk: ParseInto: response is not valid JSON for the target type: %w", err)
}
return out, nil
}
38 changes: 38 additions & 0 deletions parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package hawksdk

import "testing"

func TestParseInto(t *testing.T) {
type result struct {
Answer int `json:"answer"`
Note string `json:"note"`
}

resp := &ChatResponse{Response: `{"answer": 42, "note": "ok"}`}
got, err := ParseInto[result](resp)
if err != nil {
t.Fatalf("ParseInto() error: %v", err)
}
if got.Answer != 42 || got.Note != "ok" {
t.Errorf("ParseInto() = %+v, want {42 ok}", got)
}
}

func TestParseIntoMalformedJSON(t *testing.T) {
type result struct {
Answer int `json:"answer"`
}

resp := &ChatResponse{Response: "not json at all"}
if _, err := ParseInto[result](resp); err == nil {
t.Fatal("expected an error for malformed JSON, got nil")
}
}

func TestParseIntoNilResponse(t *testing.T) {
type result struct{}

if _, err := ParseInto[result](nil); err == nil {
t.Fatal("expected an error for nil response, got nil")
}
}
23 changes: 13 additions & 10 deletions tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ type ToolCall struct {

// ToolResult holds the result of executing a tool call.
type ToolResult struct {
// ToolCallID is the ID of the tool call this result corresponds to.
ToolCallID string `json:"tool_call_id"`
// ToolUseID is the ID of the tool use this result corresponds to.
// Hawk's daemon keys tool results by the `tool_use_id` field it emitted
// in the assistant's tool_use block (Anthropic/MCP convention), not OpenAI's
// `tool_call_id` — the daemon would otherwise drop unmatched results.
ToolUseID string `json:"tool_use_id"`

// Content is the string result from the tool execution.
Content string `json:"content"`
Expand Down Expand Up @@ -132,26 +135,26 @@ func (c *Client) ChatWithTools(ctx context.Context, req ChatRequest, tools []Too
tool, ok := toolMap[tc.Name]
if !ok {
toolResults = append(toolResults, ToolResult{
ToolCallID: tc.ID,
Content: fmt.Sprintf("error: unknown tool %q", tc.Name),
IsError: true,
ToolUseID: tc.ID,
Content: fmt.Sprintf("error: unknown tool %q", tc.Name),
IsError: true,
})
continue
}

result, err := tool.Run(ctx, tc.Arguments)
if err != nil {
toolResults = append(toolResults, ToolResult{
ToolCallID: tc.ID,
Content: fmt.Sprintf("error: %s", err.Error()),
IsError: true,
ToolUseID: tc.ID,
Content: fmt.Sprintf("error: %s", err.Error()),
IsError: true,
})
continue
}

toolResults = append(toolResults, ToolResult{
ToolCallID: tc.ID,
Content: result,
ToolUseID: tc.ID,
Content: result,
})
}

Expand Down
Loading