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
109 changes: 109 additions & 0 deletions internal/engine/agent_resume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package engine

import (
"testing"

agentcontracts "github.com/GrayCodeAI/hawk-core-contracts/agent"
"github.com/GrayCodeAI/hawk/internal/session"
"github.com/GrayCodeAI/hawk/internal/storage"
"github.com/GrayCodeAI/hawk/internal/tool"
)

func TestSubAgentResume_ReplaysTranscriptMessages(t *testing.T) {
tempDir := t.TempDir()
storage.SetTestDirs(t, tempDir)

// 1. Create and save a prior session
prior := &session.Session{
ID: "subagent-prior-123",
Model: "test-model",
Messages: []session.Message{
{Role: "user", Content: "Find all auth handlers."},
{Role: "assistant", Content: "Auth handlers are in internal/auth/handler.go."},
},
}
if err := session.Save(prior); err != nil {
t.Fatalf("failed to save prior session: %v", err)
}

// 2. Set up parent session
reg := tool.NewRegistry()
parent := NewSession("", "", "You are parent assistant", reg)

req := agentcontracts.SpawnRequest{
Prompt: "Where is the token validation function?",
SubagentType: "explore",
ResumeFrom: "subagent-prior-123",
}

norm, err := req.Normalize()
if err != nil {
t.Fatalf("Normalize failed: %v", err)
}

sub := parent.SubSession("", "", reg)
if norm.ResumeFrom != "" {
if priorSession, loadErr := session.Load(norm.ResumeFrom); loadErr == nil && priorSession != nil {
for _, m := range priorSession.Messages {
sub.Persistence().AddMessage(m.Role, m.Content)
}
}
}
sub.AddUser(norm.Prompt)

// 3. Verify that the sub-session transcript has the restored prior messages
msgs := sub.Persistence().Messages()
if len(msgs) != 3 {
t.Fatalf("expected 3 messages in sub-session transcript, got %d", len(msgs))
}

if msgs[0].Role != "user" || msgs[0].Content != "Find all auth handlers." {
t.Errorf("msg[0] = %+v, want user 'Find all auth handlers.'", msgs[0])
}
if msgs[1].Role != "assistant" || msgs[1].Content != "Auth handlers are in internal/auth/handler.go." {
t.Errorf("msg[1] = %+v, want assistant findings", msgs[1])
}
if msgs[2].Role != "user" || msgs[2].Content != "Where is the token validation function?" {
t.Errorf("msg[2] = %+v, want user new prompt", msgs[2])
}
}

func TestSubAgentResume_FallbackOnMissingSession(t *testing.T) {
tempDir := t.TempDir()
storage.SetTestDirs(t, tempDir)

reg := tool.NewRegistry()
parent := NewSession("", "", "You are parent assistant", reg)

req := agentcontracts.SpawnRequest{
Prompt: "Continue analysis.",
SubagentType: "explore",
ResumeFrom: "nonexistent-subagent-999",
}

norm, err := req.Normalize()
if err != nil {
t.Fatalf("Normalize failed: %v", err)
}

sub := parent.SubSession("", "", reg)
prompt := norm.Prompt
if norm.ResumeFrom != "" {
if priorSession, loadErr := session.Load(norm.ResumeFrom); loadErr == nil && priorSession != nil {
for _, m := range priorSession.Messages {
sub.Persistence().AddMessage(m.Role, m.Content)
}
} else {
prompt = "Resume prior subagent " + norm.ResumeFrom + ".\n\n" + prompt
}
}
sub.AddUser(prompt)

msgs := sub.Persistence().Messages()
if len(msgs) != 1 {
t.Fatalf("expected 1 message in fallback, got %d", len(msgs))
}
if msgs[0].Content != "Resume prior subagent nonexistent-subagent-999.\n\nContinue analysis." {
t.Errorf("unexpected fallback prompt: %q", msgs[0].Content)
}
}
10 changes: 8 additions & 2 deletions internal/engine/agent_session_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/GrayCodeAI/hawk/internal/hooks"
"github.com/GrayCodeAI/hawk/internal/prompts"
"github.com/GrayCodeAI/hawk/internal/sandbox"
"github.com/GrayCodeAI/hawk/internal/session"
"github.com/GrayCodeAI/hawk/internal/tool"
)

Expand Down Expand Up @@ -247,8 +248,13 @@ func (s *Session) spawnSubAgent(ctx context.Context, norm agentcontracts.Normali
prompt = fmt.Sprintf("Working directory: %s\n\n%s", workDir, prompt)
}
if norm.ResumeFrom != "" {
// True transcript resume lands with taskruntime persistence; surface the id.
prompt = fmt.Sprintf("Resume prior subagent %s.\n\n%s", norm.ResumeFrom, prompt)
if priorSession, loadErr := session.Load(norm.ResumeFrom); loadErr == nil && priorSession != nil {
for _, m := range priorSession.Messages {
sub.Persistence().AddMessage(m.Role, m.Content)
}
} else {
prompt = fmt.Sprintf("Resume prior subagent %s.\n\n%s", norm.ResumeFrom, prompt)
}
}
sub.AddUser(prompt)

Expand Down
134 changes: 134 additions & 0 deletions internal/engine/announcements.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package engine

import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)

// AnnouncementKind denotes the category/urgency of an in-session announcement.
type AnnouncementKind string

const (
AnnouncementInfo AnnouncementKind = "info"
AnnouncementWarning AnnouncementKind = "warning"
AnnouncementSystem AnnouncementKind = "system"
AnnouncementSchedule AnnouncementKind = "schedule"
)

// Announcement represents a single broadcast notice within an active session.
type Announcement struct {
ID string `json:"id"`
Kind AnnouncementKind `json:"kind"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Read bool `json:"read"`
}

// AnnouncementFeed provides a thread-safe registry of active session announcements.
type AnnouncementFeed struct {
mu sync.RWMutex
announcements []*Announcement
}

// NewAnnouncementFeed creates an empty AnnouncementFeed.
func NewAnnouncementFeed() *AnnouncementFeed {
return &AnnouncementFeed{
announcements: make([]*Announcement, 0),
}
}

// Post broadcasts a new announcement with an optional TTL (0 = never expires).
func (af *AnnouncementFeed) Post(kind AnnouncementKind, message string, ttl time.Duration) *Announcement {
af.mu.Lock()
defer af.mu.Unlock()

now := time.Now()
var expiresAt time.Time
if ttl > 0 {
expiresAt = now.Add(ttl)
}

a := &Announcement{
ID: generateAnnouncementID(),
Kind: kind,
Message: message,
CreatedAt: now,
ExpiresAt: expiresAt,
Read: false,
}

af.announcements = append(af.announcements, a)
return a
}

// Active returns all non-expired announcements.
func (af *AnnouncementFeed) Active() []Announcement {
af.mu.Lock()
defer af.mu.Unlock()

now := time.Now()
active := make([]Announcement, 0, len(af.announcements))
remaining := make([]*Announcement, 0, len(af.announcements))

for _, a := range af.announcements {
if a.ExpiresAt.IsZero() || a.ExpiresAt.After(now) {
active = append(active, *a)
remaining = append(remaining, a)
}
}

af.announcements = remaining
return active
}

// Unread returns all active announcements that have not been acknowledged.
func (af *AnnouncementFeed) Unread() []Announcement {
active := af.Active()
unread := make([]Announcement, 0, len(active))
for _, a := range active {
if !a.Read {
unread = append(unread, a)
}
}
return unread
}

// MarkRead marks an announcement as read by its ID.
func (af *AnnouncementFeed) MarkRead(id string) bool {
af.mu.Lock()
defer af.mu.Unlock()

for _, a := range af.announcements {
if a.ID == id {
a.Read = true
return true
}
}
return false
}

// MarkAllRead marks all active announcements as read.
func (af *AnnouncementFeed) MarkAllRead() {
af.mu.Lock()
defer af.mu.Unlock()

for _, a := range af.announcements {
a.Read = true
}
}

// Clear removes all announcements.
func (af *AnnouncementFeed) Clear() {
af.mu.Lock()
defer af.mu.Unlock()
af.announcements = af.announcements[:0]
}

func generateAnnouncementID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return "ann-" + hex.EncodeToString(b)
}
63 changes: 63 additions & 0 deletions internal/engine/announcements_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package engine

import (
"testing"
"time"
)

func TestAnnouncementFeed_PostAndActive(t *testing.T) {
af := NewAnnouncementFeed()

a1 := af.Post(AnnouncementInfo, "System maintenance in 1 hour", 1*time.Hour)
a2 := af.Post(AnnouncementWarning, "Rate limit approaching", 0)

active := af.Active()
if len(active) != 2 {
t.Fatalf("expected 2 active announcements, got %d", len(active))
}

unread := af.Unread()
if len(unread) != 2 {
t.Fatalf("expected 2 unread announcements, got %d", len(unread))
}

if !af.MarkRead(a1.ID) {
t.Error("expected MarkRead to succeed for a1")
}

unread = af.Unread()
if len(unread) != 1 || unread[0].ID != a2.ID {
t.Errorf("expected 1 unread announcement (a2), got %v", unread)
}

af.MarkAllRead()
if len(af.Unread()) != 0 {
t.Error("expected 0 unread announcements after MarkAllRead")
}
}

func TestAnnouncementFeed_Expiration(t *testing.T) {
af := NewAnnouncementFeed()

// Post with short TTL
af.Post(AnnouncementInfo, "Temporary notice", 10*time.Millisecond)
af.Post(AnnouncementSystem, "Permanent notice", 0)

time.Sleep(25 * time.Millisecond)

active := af.Active()
if len(active) != 1 || active[0].Message != "Permanent notice" {
t.Errorf("expected only permanent notice after expiration, got %v", active)
}
}

func TestAnnouncementFeed_Clear(t *testing.T) {
af := NewAnnouncementFeed()
af.Post(AnnouncementInfo, "Notice 1", 0)
af.Post(AnnouncementInfo, "Notice 2", 0)

af.Clear()
if len(af.Active()) != 0 {
t.Error("expected 0 announcements after Clear")
}
}
17 changes: 17 additions & 0 deletions internal/engine/persistence_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,23 @@ func (s *PersistenceService) AddUser(content string) {
s.AppendUserJournaled(types.EyrieMessage{Role: "user", Content: content})
}

// AddMessage appends a message with the specified role and content.
func (s *PersistenceService) AddMessage(role, content string) {
if s == nil {
return
}
switch strings.ToLower(role) {
case "assistant":
s.AddAssistant(content)
case "user":
s.AddUser(content)
default:
s.mu.Lock()
s.messages = append(s.messages, types.EyrieMessage{Role: role, Content: content})
s.mu.Unlock()
}
}

// AddUserWithImage appends a user message with an inline image.
// The image is stored as a data URL ("data:<imageType>;base64,<base64>")
// so the LLM-side eyrie client can decode it from the message body
Expand Down
Loading
Loading