diff --git a/iterator.go b/iterator.go new file mode 100644 index 0000000..0ae800a --- /dev/null +++ b/iterator.go @@ -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 + } + } +} diff --git a/iterator_test.go b/iterator_test.go new file mode 100644 index 0000000..ad211ca --- /dev/null +++ b/iterator_test.go @@ -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") + } +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..ad89e59 --- /dev/null +++ b/parse.go @@ -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 +} diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 0000000..38c4797 --- /dev/null +++ b/parse_test.go @@ -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") + } +} diff --git a/tools.go b/tools.go index 9c62704..5c3a7f4 100644 --- a/tools.go +++ b/tools.go @@ -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"` @@ -132,9 +135,9 @@ 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 } @@ -142,16 +145,16 @@ func (c *Client) ChatWithTools(ctx context.Context, req ChatRequest, tools []Too 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, }) }