ChatClient Interface Improvements Specification¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-6) (AI drafting assistant)
- Date
- 21 March 2026
- Status
- DRAFT
Overview¶
The ChatClient interface has three deficiencies:
-
Missing
context.Context:Add()andAsk()lack context parameters whileChat()has one. This prevents callers from controlling cancellation or deadlines for individual calls. All four provider implementations storecontext.Contextas a struct field, which the Go documentation explicitly warns against. -
Undocumented thread safety: All providers mutate message history slices without synchronisation. Whether this is intentional or an oversight is unclear to consumers.
-
Underspecified contract: The interface lacks documentation about behaviour when
Ask()is called without aResponseSchema, whetherAdd()messages persist acrossChat()calls, and error/retry semantics.
Design Decisions¶
Context on every method: All methods that perform I/O or could block accept context.Context as the first parameter. This follows standard Go library conventions.
Not goroutine-safe (documented): Rather than adding mutex overhead, we document that ChatClient implementations are not safe for concurrent use โ consistent with http.Request, json.Decoder, and most Go types. Each goroutine should create its own client instance.
No retry logic: The interface does not specify retry behaviour. Rate limit errors and transient failures surface directly to the caller. Retry logic belongs in a higher-level wrapper if needed.
Public API Changes¶
Modified: ChatClient Interface¶
// ChatClient defines the interface for interacting with a chat service.
//
// Implementations are NOT safe for concurrent use by multiple goroutines.
// Each goroutine should use its own ChatClient instance.
//
// Message history from Add() calls persists across Chat() and Ask() calls
// within the same client instance. To start a fresh conversation, create
// a new client via chat.New().
type ChatClient interface {
// Add appends a user message to the conversation history without
// triggering a completion. The message persists for subsequent
// Chat() or Ask() calls.
Add(ctx context.Context, prompt string) error
// Ask sends a question and unmarshals the structured response into
// target. If Config.ResponseSchema was set during construction, the
// provider enforces that schema. If no schema is set, the provider
// returns the raw text content unmarshalled into target (which must
// be a *string or implement json.Unmarshaler).
Ask(ctx context.Context, question string, target any) error
// SetTools configures the tools available to the AI. This replaces
// (not appends to) any previously set tools.
SetTools(tools []Tool) error
// Chat sends a message and returns the response content. If tools
// are configured, the provider handles tool calls internally via a
// ReAct loop bounded by Config.MaxSteps (default 20).
Chat(ctx context.Context, prompt string) (string, error)
}
Removed: Stored Context from Provider Structs¶
Each provider struct loses its ctx context.Context field:
// Before:
type Claude struct {
ctx context.Context // REMOVED
client anthropic.Client
// ...
}
// After:
type Claude struct {
client anthropic.Client
// ...
}
Internal Implementation¶
Provider Changes (All Four)¶
For each provider (Claude, OpenAI, Gemini, ClaudeLocal):
- Remove
ctx context.Contextfrom struct fields - Update
Add(ctx context.Context, prompt string) error - Update
Ask(ctx context.Context, question string, target any) error - Factory functions (
newClaude,newOpenAI, etc.) no longer storectxโ the context passed toNew()is only used for client initialisation (e.g., Gemini'sgenai.NewClient)
Claude Example¶
func (c *Claude) Add(ctx context.Context, prompt string) error {
c.messages = append(c.messages, anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)))
return nil
}
func (c *Claude) Ask(ctx context.Context, question string, target any) error {
c.messages = append(c.messages, anthropic.NewUserMessage(anthropic.NewTextBlock(question)))
// ...
resp, err := c.client.Messages.New(ctx, params) // ctx passed through
// ...
}
OpenAI Example¶
func (a *OpenAI) Ask(ctx context.Context, question string, target any) error {
// ...
res, err := a.oai.Chat.Completions.New(ctx, a.params) // ctx passed through
// ...
}
Gemini โ Special Case¶
Gemini's genai.NewClient requires a context at construction time. This context is used for the HTTP client setup, not for individual requests. The factory stores the client (which embeds its own transport context), and per-request contexts are passed through:
func newGemini(ctx context.Context, p *props.Props, cfg Config) (ChatClient, error) {
client, err := genai.NewClient(ctx, &genai.ClientConfig{APIKey: token})
// ctx is NOT stored โ only used for client init
return &Gemini{client: client, ...}, nil
}
func (g *Gemini) Ask(ctx context.Context, question string, target any) error {
chat, err := g.client.Chats.Create(ctx, g.model, askCfg, g.history)
// ...
}
Caller Updates¶
All callers of Add() and Ask() must pass a context. Key callsites:
| File | Method | Change |
|---|---|---|
internal/generator/docs.go |
generatePackageDocs |
Pass ctx from generator method |
internal/generator/commands.go |
generateWithAI |
Pass ctx from generator method |
pkg/docs/ask.go |
AskQuestion |
Pass ctx from command context |
Mock Regeneration¶
Regenerate mocks via mockery:
The ChatClient mock will automatically gain the new method signatures.
Project Structure¶
pkg/chat/
โโโ client.go โ MODIFIED: interface + godoc
โโโ claude.go โ MODIFIED: remove ctx field, update Add/Ask
โโโ openai.go โ MODIFIED: remove ctx field, update Add/Ask
โโโ gemini.go โ MODIFIED: remove ctx field, update Add/Ask
โโโ claude_local.go โ MODIFIED: remove ctx field, update Add/Ask
โโโ client_test.go โ MODIFIED: update test signatures
internal/generator/
โโโ docs.go โ MODIFIED: pass ctx to Add/Ask
โโโ commands.go โ MODIFIED: pass ctx to Add/Ask
pkg/docs/
โโโ ask.go โ MODIFIED: pass ctx to Add/Ask
mocks/
โโโ (regenerated)
Testing Strategy¶
| Test | Scenario |
|---|---|
TestChatClient_Add_WithContext |
Context cancellation before Add โ returns context error or succeeds (Add is local) |
TestChatClient_Ask_ContextCancelled |
Cancelled context โ API call fails with context error |
TestChatClient_Ask_WithDeadline |
Deadline exceeded โ appropriate error returned |
TestChatClient_MessagePersistence |
Add โ Chat โ messages from Add present in conversation |
TestChatClient_SetTools_Replaces |
SetTools twice โ only second set active |
| Existing provider tests | Updated signatures, same assertions |
Coverage¶
- Target: 90%+ for
pkg/chat/.
Linting¶
golangci-lint run --fixmust pass.- No new
nolintdirectives. - The
contextchecklinter will now pass forAddandAsk(previously they used stored contexts).
Documentation¶
- Comprehensive godoc on
ChatClientinterface (see Public API Changes). - Godoc on each method specifying behaviour, error conditions, and context usage.
- Update
docs/components/chat.mdwith: - Thread safety guidance
- Context usage examples
- Message persistence explanation
Backwards Compatibility¶
- Breaking change:
Add()andAsk()signatures change. All callers must be updated. - Mock regeneration required: Mocks must be regenerated.
- Provider factory context: The
ctxparameter toNew()/ factory functions is still required for provider initialisation but is no longer stored.
Future Considerations¶
- Context-aware Add: Currently
Add()is a local append and ignores the context. If a provider needs to validate prompts server-side, the context is already available. - Streaming: When streaming support is added (separate spec), the
Stream()method will naturally acceptcontext.Context.
Implementation Phases¶
Phase 1 โ Interface Change¶
- Update
ChatClientinterface inclient.go - Add comprehensive godoc
Phase 2 โ Provider Updates¶
- Update Claude: remove
ctxfield, updateAdd/Ask - Update OpenAI: same
- Update Gemini: same
- Update ClaudeLocal: same
Phase 3 โ Caller Updates¶
- Update
internal/generator/docs.go - Update
internal/generator/commands.go - Update
pkg/docs/ask.go - Regenerate mocks
Phase 4 โ Tests¶
- Update existing tests for new signatures
- Add contract tests for documented behaviour
- Run full suite with race detector
Verification¶
go build ./...
go test -race ./pkg/chat/... ./internal/generator/... ./pkg/docs/...
go test ./...
golangci-lint run --fix
mockery # regenerate mocks
# Verify no stored context in provider structs
grep -n 'ctx.*context\.Context' pkg/chat/claude.go pkg/chat/openai.go pkg/chat/gemini.go pkg/chat/claude_local.go
# Should only appear in method parameters, not struct fields