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
1 change: 1 addition & 0 deletions cmd/autoinit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
// produces a recognized context file and that MaybeRun invokes it exactly once
// for a fresh project, then gates on the marker thereafter.
func TestAutoInitRunner_WritesContextFileOnce(t *testing.T) {
t.Setenv("HAWK_STATE_DIR", filepath.Join(t.TempDir(), "state"))
root := t.TempDir()
// A trivial Go file so BuildHierarchy has something to summarize.
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc Foo() {}\n"), 0o644); err != nil {
Expand Down
12 changes: 8 additions & 4 deletions internal/autoinit/autoinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ func HasRun(root string) bool {
// 4. Otherwise -> run, then mark.
//
// The marker is written whenever a run is attempted (even on runner error) so
// a failing analysis is not retried on every invocation. MaybeRun never
// returns an error for gating decisions; it only propagates a runner error.
// a failing analysis is not retried on every invocation. Marker write failures
// are returned before running because otherwise auto-init can retry forever.
func MaybeRun(ctx context.Context, opts Options) (Decision, error) {
if opts.Root == "" {
return Decision{Skipped: "no project root"}, nil
Expand All @@ -121,13 +121,17 @@ func MaybeRun(ctx context.Context, opts Options) (Decision, error) {
if !opts.Force && HasContext(opts.Root) {
// Project already has context: record the marker so we never probe
// again, and skip the analysis.
_ = writeMarker(opts.Root)
if err := writeMarker(opts.Root); err != nil {
return Decision{Skipped: "project already has context"}, err
}
return Decision{Skipped: "project already has context"}, nil
}

// Write the marker before running so a crash mid-analysis does not cause a
// retry storm on every subsequent invocation.
_ = writeMarker(opts.Root)
if err := writeMarker(opts.Root); err != nil {
return Decision{Skipped: "marker write failed"}, err
}

if opts.Run == nil {
return Decision{Skipped: "no runner configured"}, nil
Expand Down
36 changes: 36 additions & 0 deletions internal/autoinit/autoinit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
)

func TestMaybeRun_RunsOnceThenSkips(t *testing.T) {
setTestStateDir(t)
root := t.TempDir()
calls := 0
run := func(ctx context.Context, r string) error {
Expand Down Expand Up @@ -50,6 +51,7 @@ func TestMaybeRun_RunsOnceThenSkips(t *testing.T) {
}

func TestMaybeRun_SkipsWhenContextExists(t *testing.T) {
setTestStateDir(t)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("# x"), 0o644); err != nil {
t.Fatal(err)
Expand All @@ -71,6 +73,7 @@ func TestMaybeRun_SkipsWhenContextExists(t *testing.T) {
}

func TestMaybeRun_Disabled(t *testing.T) {
setTestStateDir(t)
root := t.TempDir()
calls := 0
run := func(ctx context.Context, r string) error { calls++; return nil }
Expand All @@ -92,6 +95,7 @@ func TestMaybeRun_Disabled(t *testing.T) {
}

func TestMaybeRun_ForceIgnoresExistingContext(t *testing.T) {
setTestStateDir(t)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "HAWK.md"), []byte("# x"), 0o644); err != nil {
t.Fatal(err)
Expand All @@ -109,6 +113,7 @@ func TestMaybeRun_ForceIgnoresExistingContext(t *testing.T) {
}

func TestMaybeRun_MarkerWrittenOnRunnerError(t *testing.T) {
setTestStateDir(t)
root := t.TempDir()
run := func(ctx context.Context, r string) error { return context.Canceled }

Expand All @@ -125,6 +130,7 @@ func TestMaybeRun_MarkerWrittenOnRunnerError(t *testing.T) {
}

func TestMaybeRun_NoRunnerStillGates(t *testing.T) {
setTestStateDir(t)
root := t.TempDir()
dec, err := MaybeRun(context.Background(), Options{Root: root})
if err != nil {
Expand All @@ -138,6 +144,31 @@ func TestMaybeRun_NoRunnerStillGates(t *testing.T) {
}
}

func TestMaybeRun_ReturnsMarkerWriteError(t *testing.T) {
root := t.TempDir()
stateFile := filepath.Join(t.TempDir(), "state")
if err := os.WriteFile(stateFile, []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("HAWK_STATE_DIR", stateFile)
calls := 0
run := func(context.Context, string) error {
calls++
return nil
}

dec, err := MaybeRun(context.Background(), Options{Root: root, Run: run})
if err == nil {
t.Fatal("expected marker write error")
}
if dec.Skipped != "marker write failed" {
t.Fatalf("Skipped = %q, want marker write failed", dec.Skipped)
}
if calls != 0 {
t.Fatalf("runner should not run when marker cannot be written, got %d calls", calls)
}
}

func TestHasContext(t *testing.T) {
root := t.TempDir()
if HasContext(root) {
Expand All @@ -150,3 +181,8 @@ func TestHasContext(t *testing.T) {
t.Error("dir with CLAUDE.md should have context")
}
}

func setTestStateDir(t *testing.T) {
t.Helper()
t.Setenv("HAWK_STATE_DIR", filepath.Join(t.TempDir(), "state"))
}
30 changes: 30 additions & 0 deletions internal/engine/provider_chat_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package engine

import (
"context"
"errors"

"github.com/GrayCodeAI/hawk/internal/types"
)
Expand All @@ -16,14 +17,43 @@ func NewProviderChatClient(p types.ChatProvider) ChatClient {
return &providerChatClient{p: p}
}

// NewUnavailableChatClient preserves Session construction while surfacing
// Eyrie transport setup failures at the first chat call.
func NewUnavailableChatClient(err error) ChatClient {
if err == nil {
err = errors.New("hawk: chat transport unavailable")
}
return &unavailableChatClient{err: err}
}

func (w *providerChatClient) Chat(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) {
if w == nil || w.p == nil {
return nil, errors.New("hawk: chat provider unavailable")
}
return w.p.Chat(ctx, messages, opts)
}

func (w *providerChatClient) StreamChatContinue(ctx context.Context, messages []types.EyrieMessage, opts types.ChatOptions, cfg types.ContinuationConfig) (*types.StreamResult, error) {
if w == nil || w.p == nil {
return nil, errors.New("hawk: chat provider unavailable")
}
return types.StreamChatWithContinuation(ctx, w.p, messages, opts, cfg)
}

func (w *providerChatClient) SetAPIKey(_, _ string) {
// Credentials live on concrete adapters inside DeploymentRouter; Session env keys are unused here.
}

type unavailableChatClient struct {
err error
}

func (c *unavailableChatClient) Chat(context.Context, []types.EyrieMessage, types.ChatOptions) (*types.EyrieResponse, error) {
return nil, c.err
}

func (c *unavailableChatClient) StreamChatContinue(context.Context, []types.EyrieMessage, types.ChatOptions, types.ContinuationConfig) (*types.StreamResult, error) {
return nil, c.err
}

func (c *unavailableChatClient) SetAPIKey(_, _ string) {}
22 changes: 15 additions & 7 deletions internal/engine/session_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package engine
import (
"context"
"errors"
"fmt"
"strings"

"github.com/GrayCodeAI/eyrie/runtime"
Expand All @@ -14,7 +15,7 @@ import (
func transportBoolPtr(v bool) *bool { return &v }

// BuildChatClient returns an LLM client and whether deployment routing is active.
func BuildChatClient(ctx context.Context, selection runtime.SelectionState, legacyProvider string) (ChatClient, string, bool) {
func BuildChatClient(ctx context.Context, selection runtime.SelectionState, legacyProvider string) (ChatClient, string, bool, error) {
provider := strings.TrimSpace(selection.Provider)
if provider == "" {
provider = legacyProvider
Expand All @@ -33,16 +34,20 @@ func BuildChatClient(ctx context.Context, selection runtime.SelectionState, lega
if label == "" {
label = provider
}
return NewProviderChatClient(types.WrapClientProvider(transport.Provider)), label, transport.Selection.DeploymentRouting
return NewProviderChatClient(types.WrapClientProvider(transport.Provider)), label, transport.Selection.DeploymentRouting, nil
}
return NewProviderChatClient(types.NewLazyChatProvider(&types.ClientConfig{
Provider: provider,
})), provider, false
if err != nil {
return nil, provider, false, fmt.Errorf("eyrie transport: %w", err)
}
return nil, provider, false, fmt.Errorf("eyrie transport: no provider resolved for %q", provider)
}

// NewHawkSession constructs a Session using an engine-resolved selection.
func NewHawkSession(ctx context.Context, selection runtime.SelectionState, provider, model, systemPrompt string, registry *tool.Registry) *Session {
chat, label, deploy := BuildChatClient(ctx, selection, provider)
chat, label, deploy, err := BuildChatClient(ctx, selection, provider)
if err != nil {
chat = NewUnavailableChatClient(err)
}
resolvedModel := strings.TrimSpace(selection.Model)
if resolvedModel == "" {
resolvedModel = model
Expand All @@ -55,7 +60,10 @@ func RebuildSessionTransport(ctx context.Context, s *Session, selection runtime.
if s == nil {
return errors.New("session is nil")
}
chat, label, deploy := BuildChatClient(ctx, selection, legacyProvider)
chat, label, deploy, err := BuildChatClient(ctx, selection, legacyProvider)
if err != nil {
return err
}
s.ReattachTransport(chat, label, deploy)
return nil
}
Loading