Skip to content

Commit a7e756d

Browse files
committed
feat: wire up tool execution with Turn abstraction
- Add Turn interface to handle conversation loop (send → stream → execute tools → repeat) - Simplify domain/tool to just wire types (ToolUse, ToolResult, ToolInputDelta) - Refactor sqlite.DB: Database interface → DB interface, DB struct → database (unexported) - Add BeginTx, WithTx, Raw methods to sqlite.DB interface - Query tool uses PRAGMA query_only for read-only enforcement - Add tests for query tool and turn execution loop - Document test helpers in TESTING.md
1 parent a9fd4b9 commit a7e756d

45 files changed

Lines changed: 947 additions & 682 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/TESTING.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,26 @@ func TestSync_Connect(t *testing.T) {
8484

8585
Exception: tests using `t.Setenv()` can't run in parallel.
8686

87+
### Use Test Helpers
88+
89+
Use the `*test` packages for common test doubles:
90+
91+
```go
92+
import (
93+
"github.com/usetero/cli/internal/log/logtest"
94+
"github.com/usetero/cli/internal/chat/chattest"
95+
"github.com/usetero/cli/internal/sqlite/sqlitetest"
96+
)
97+
98+
func TestFoo(t *testing.T) {
99+
logger := logtest.New(t) // logs appear on test failure
100+
client := &chattest.MockClient{...}
101+
db := sqlitetest.OpenBareDB(t)
102+
}
103+
```
104+
105+
`logtest.New(t)` is preferred over discarding logs—you'll want them when debugging failures.
106+
87107
## File Naming
88108

89109
| Type | File | Build Tag | Function Prefix |

‎internal/api/message_service.go‎

Lines changed: 15 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77

88
"github.com/usetero/cli/internal/api/gen"
99
"github.com/usetero/cli/internal/domain"
10-
"github.com/usetero/cli/internal/domain/tool"
1110
"github.com/usetero/cli/internal/log"
1211
)
1312

@@ -119,21 +118,21 @@ func toContentBlockInput(block domain.Block) (gen.ContentBlockInput, error) {
119118
}, nil
120119

121120
case domain.BlockTypeToolUse:
122-
input, err := toToolInput(block.ToolUse)
121+
input, err := rawJSONToMap(block.ToolUse.Input)
123122
if err != nil {
124123
return gen.ContentBlockInput{}, fmt.Errorf("tool_use block: %w", err)
125124
}
126125
return gen.ContentBlockInput{
127126
Type: gen.ContentBlockTypeToolUse,
128127
ToolUse: &gen.ToolUseInput{
129128
Id: block.ToolUse.ID,
130-
Name: string(block.ToolUse.Name),
129+
Name: block.ToolUse.Name,
131130
Input: input,
132131
},
133132
}, nil
134133

135134
case domain.BlockTypeToolResult:
136-
content, err := toToolResultContent(block.ToolResult)
135+
content, err := rawJSONToMapPtr(block.ToolResult.Content)
137136
if err != nil {
138137
return gen.ContentBlockInput{}, fmt.Errorf("tool_result block: %w", err)
139138
}
@@ -162,102 +161,24 @@ func toContentBlockInput(block domain.Block) (gen.ContentBlockInput, error) {
162161
return gen.ContentBlockInput{}, fmt.Errorf("unknown block type: %s", block.Type)
163162
}
164163

165-
func toToolInput(use *tool.Use) (map[string]any, error) {
166-
switch use.Name {
167-
case tool.AddContext:
168-
if use.AddContext == nil {
169-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
170-
}
171-
return structToMap(use.AddContext)
172-
case tool.RemoveContext:
173-
if use.RemoveContext == nil {
174-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
175-
}
176-
return structToMap(use.RemoveContext)
177-
case tool.Query:
178-
if use.Query == nil {
179-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
180-
}
181-
return structToMap(use.Query)
182-
case tool.ShowMetric:
183-
if use.ShowMetric == nil {
184-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
185-
}
186-
return structToMap(use.ShowMetric)
187-
case tool.ShowSeries:
188-
if use.ShowSeries == nil {
189-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
190-
}
191-
return structToMap(use.ShowSeries)
192-
case tool.ShowTimeSeries:
193-
if use.ShowTimeSeries == nil {
194-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
195-
}
196-
return structToMap(use.ShowTimeSeries)
197-
case tool.ShowTable:
198-
if use.ShowTable == nil {
199-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
200-
}
201-
return structToMap(use.ShowTable)
202-
case tool.StartJourney:
203-
if use.StartJourney == nil {
204-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
205-
}
206-
return structToMap(use.StartJourney)
207-
case tool.EndJourney:
208-
if use.EndJourney == nil {
209-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
210-
}
211-
return structToMap(use.EndJourney)
212-
case tool.ApprovePolicy:
213-
if use.ApprovePolicy == nil {
214-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
215-
}
216-
return structToMap(use.ApprovePolicy)
217-
case tool.DismissPolicy:
218-
if use.DismissPolicy == nil {
219-
return nil, fmt.Errorf("tool %s: missing typed input", use.Name)
220-
}
221-
return structToMap(use.DismissPolicy)
222-
}
223-
224-
return nil, fmt.Errorf("unknown tool: %s", use.Name)
225-
}
226-
227-
func toToolResultContent(result *tool.Result) (*map[string]any, error) {
228-
if result.AddContext != nil {
229-
m, err := structToMap(result.AddContext)
230-
return &m, err
231-
}
232-
if result.RemoveContext != nil {
233-
m, err := structToMap(result.RemoveContext)
234-
return &m, err
235-
}
236-
if result.Query != nil {
237-
m, err := structToMap(result.Query)
238-
return &m, err
239-
}
240-
if result.ApprovePolicy != nil {
241-
m, err := structToMap(result.ApprovePolicy)
242-
return &m, err
164+
func rawJSONToMap(data json.RawMessage) (map[string]any, error) {
165+
if len(data) == 0 {
166+
return nil, nil
243167
}
244-
if result.DismissPolicy != nil {
245-
m, err := structToMap(result.DismissPolicy)
246-
return &m, err
168+
var m map[string]any
169+
if err := json.Unmarshal(data, &m); err != nil {
170+
return nil, err
247171
}
248-
249-
// No typed result - this is OK for client-executed tools
250-
return nil, nil
172+
return m, nil
251173
}
252174

253-
func structToMap(v any) (map[string]any, error) {
254-
data, err := json.Marshal(v)
255-
if err != nil {
256-
return nil, fmt.Errorf("marshal struct: %w", err)
175+
func rawJSONToMapPtr(data json.RawMessage) (*map[string]any, error) {
176+
if len(data) == 0 {
177+
return nil, nil
257178
}
258179
var m map[string]any
259180
if err := json.Unmarshal(data, &m); err != nil {
260-
return nil, fmt.Errorf("unmarshal to map: %w", err)
181+
return nil, err
261182
}
262-
return m, nil
183+
return &m, nil
263184
}

‎internal/api/message_service_test.go‎

Lines changed: 10 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ package api_test
22

33
import (
44
"context"
5+
"encoding/json"
56
"testing"
67

78
"github.com/usetero/cli/internal/api"
89
"github.com/usetero/cli/internal/api/apitest"
910
"github.com/usetero/cli/internal/api/gen"
1011
"github.com/usetero/cli/internal/domain"
11-
"github.com/usetero/cli/internal/domain/tool"
1212
"github.com/usetero/cli/internal/log/logtest"
1313
)
1414

@@ -102,7 +102,7 @@ func TestMessageService_CreateMessage(t *testing.T) {
102102
}
103103
})
104104

105-
t.Run("sends tool_use block with typed input", func(t *testing.T) {
105+
t.Run("sends tool_use block with raw input", func(t *testing.T) {
106106
t.Parallel()
107107

108108
var captured gen.CreateMessageInput
@@ -122,12 +122,10 @@ func TestMessageService_CreateMessage(t *testing.T) {
122122
Content: []domain.Block{
123123
{
124124
Type: domain.BlockTypeToolUse,
125-
ToolUse: &tool.Use{
126-
ID: "tool-1",
127-
Name: tool.Query,
128-
Query: &tool.QueryInput{
129-
SQL: "SELECT * FROM logs",
130-
},
125+
ToolUse: &domain.ToolUse{
126+
ID: "tool-1",
127+
Name: "query",
128+
Input: json.RawMessage(`{"sql": "SELECT * FROM logs"}`),
131129
},
132130
},
133131
},
@@ -157,73 +155,6 @@ func TestMessageService_CreateMessage(t *testing.T) {
157155
}
158156
})
159157

160-
t.Run("rejects tool_use with nil typed input", func(t *testing.T) {
161-
t.Parallel()
162-
163-
mockClient := &apitest.MockClient{
164-
CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) {
165-
t.Error("CreateMessage should not be called")
166-
return &gen.CreateMessageResponse{}, nil
167-
},
168-
}
169-
170-
svc := api.NewMessageService(mockClient, logtest.New(t))
171-
172-
msg := &domain.Message{
173-
ID: "msg-123",
174-
ConversationID: "conv-456",
175-
Role: domain.RoleAssistant,
176-
Content: []domain.Block{
177-
{
178-
Type: domain.BlockTypeToolUse,
179-
ToolUse: &tool.Use{
180-
ID: "tool-1",
181-
Name: tool.Query,
182-
Query: nil, // Missing typed input
183-
},
184-
},
185-
},
186-
}
187-
188-
err := svc.CreateMessage(context.Background(), msg)
189-
if err == nil {
190-
t.Fatal("CreateMessage() expected error for nil typed input, got nil")
191-
}
192-
})
193-
194-
t.Run("rejects unknown tool name", func(t *testing.T) {
195-
t.Parallel()
196-
197-
mockClient := &apitest.MockClient{
198-
CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) {
199-
t.Error("CreateMessage should not be called")
200-
return &gen.CreateMessageResponse{}, nil
201-
},
202-
}
203-
204-
svc := api.NewMessageService(mockClient, logtest.New(t))
205-
206-
msg := &domain.Message{
207-
ID: "msg-123",
208-
ConversationID: "conv-456",
209-
Role: domain.RoleAssistant,
210-
Content: []domain.Block{
211-
{
212-
Type: domain.BlockTypeToolUse,
213-
ToolUse: &tool.Use{
214-
ID: "tool-1",
215-
Name: tool.Name("unknown_tool"),
216-
},
217-
},
218-
},
219-
}
220-
221-
err := svc.CreateMessage(context.Background(), msg)
222-
if err == nil {
223-
t.Fatal("CreateMessage() expected error for unknown tool, got nil")
224-
}
225-
})
226-
227158
t.Run("rejects streaming block types", func(t *testing.T) {
228159
t.Parallel()
229160

@@ -265,7 +196,7 @@ func TestMessageService_CreateMessage(t *testing.T) {
265196
}
266197
})
267198

268-
t.Run("sends tool_result with typed content", func(t *testing.T) {
199+
t.Run("sends tool_result with raw content", func(t *testing.T) {
269200
t.Parallel()
270201

271202
var captured gen.CreateMessageInput
@@ -285,12 +216,9 @@ func TestMessageService_CreateMessage(t *testing.T) {
285216
Content: []domain.Block{
286217
{
287218
Type: domain.BlockTypeToolResult,
288-
ToolResult: &tool.Result{
219+
ToolResult: &domain.ToolResult{
289220
ToolUseID: "tool-1",
290-
Query: &tool.QueryResult{
291-
Columns: []string{"id", "name"},
292-
Rows: [][]any{{"1", "foo"}},
293-
},
221+
Content: json.RawMessage(`{"columns": ["id", "name"], "rows": [["1", "foo"]]}`),
294222
},
295223
},
296224
},
@@ -340,7 +268,7 @@ func TestMessageService_CreateMessage(t *testing.T) {
340268
Content: []domain.Block{
341269
{
342270
Type: domain.BlockTypeToolResult,
343-
ToolResult: &tool.Result{
271+
ToolResult: &domain.ToolResult{
344272
ToolUseID: "tool-1",
345273
IsError: true,
346274
Error: "something went wrong",

‎internal/chat/accumulator_test.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package chat
22

33
import (
4+
"encoding/json"
45
"testing"
56

67
"github.com/usetero/cli/internal/domain"
7-
"github.com/usetero/cli/internal/domain/tool"
88
)
99

1010
func TestAccumulator_TextDeltas(t *testing.T) {
@@ -125,10 +125,10 @@ func TestAccumulator_ToolUse(t *testing.T) {
125125
// Tool use (comes as complete block)
126126
acc.Handle(Event{Block: domain.Block{
127127
Type: domain.BlockTypeToolUse,
128-
ToolUse: &tool.Use{
129-
ID: "tool-1",
130-
Name: tool.Query,
131-
RawInput: `{"limit": 10}`,
128+
ToolUse: &domain.ToolUse{
129+
ID: "tool-1",
130+
Name: "query",
131+
Input: json.RawMessage(`{"limit": 10}`),
132132
},
133133
}})
134134

‎internal/domain/block.go‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ package domain
33
import (
44
"encoding/json"
55
"fmt"
6-
7-
"github.com/usetero/cli/internal/domain/tool"
86
)
97

108
// BlockType identifies the kind of content block.
@@ -33,8 +31,8 @@ type Block struct {
3331
Type BlockType `json:"type"`
3432
Text *TextBlock `json:"text,omitempty"`
3533
Thinking *Thinking `json:"thinking,omitempty"`
36-
ToolUse *tool.Use `json:"tool_use,omitempty"`
37-
ToolResult *tool.Result `json:"tool_result,omitempty"`
34+
ToolUse *ToolUse `json:"tool_use,omitempty"`
35+
ToolResult *ToolResult `json:"tool_result,omitempty"`
3836
ToolInputDelta string `json:"tool_input_delta,omitempty"`
3937
MessageStart *MessageStart `json:"message_start,omitempty"`
4038
MessageStop *MessageStop `json:"message_stop,omitempty"`

‎internal/domain/tool.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package domain
2+
3+
import "encoding/json"
4+
5+
// ToolUse represents the AI calling a tool.
6+
type ToolUse struct {
7+
ID string `json:"id"`
8+
Name string `json:"name"`
9+
Input json.RawMessage `json:"input"`
10+
}
11+
12+
// ToolResult is the outcome of a tool call.
13+
type ToolResult struct {
14+
ToolUseID string `json:"tool_use_id"`
15+
IsError bool `json:"is_error,omitempty"`
16+
Error string `json:"error,omitempty"`
17+
Content json.RawMessage `json:"content,omitempty"`
18+
}
19+
20+
// ToolInputDelta is a streaming fragment of tool input JSON.
21+
type ToolInputDelta struct {
22+
ToolUseID string `json:"tool_use_id"`
23+
Delta string `json:"delta"`
24+
}

0 commit comments

Comments
 (0)