0b368b3a8e
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2189 lines
75 KiB
Go
2189 lines
75 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
|
|
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
|
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// MockEventBusWithTracking provides detailed tracking of published events for testing
|
|
type MockEventBusWithTracking struct {
|
|
PublishedEvents []trackedEvent
|
|
mu sync.Mutex
|
|
}
|
|
|
|
type trackedEvent struct {
|
|
Subject string
|
|
Event *bus.Event
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) Publish(ctx context.Context, subject string, event *bus.Event) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.PublishedEvents = append(m.PublishedEvents, trackedEvent{Subject: subject, Event: event})
|
|
return nil
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) Close() {}
|
|
|
|
func (m *MockEventBusWithTracking) IsConnected() bool {
|
|
return true
|
|
}
|
|
|
|
func (m *MockEventBusWithTracking) getStreamEvents() []AgentStreamEventPayload {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var result []AgentStreamEventPayload
|
|
for _, te := range m.PublishedEvents {
|
|
if payload, ok := te.Event.Data.(AgentStreamEventPayload); ok {
|
|
result = append(result, payload)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// createTestManagerWithTracking creates a manager with a tracking event bus for testing
|
|
func createTestManagerWithTracking() (*Manager, *MockEventBusWithTracking) {
|
|
log := newTestLogger()
|
|
reg := newTestRegistry()
|
|
eventBus := &MockEventBusWithTracking{}
|
|
credsMgr := &MockCredentialsManager{}
|
|
profileResolver := &MockProfileResolver{}
|
|
mgr := NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, "", log)
|
|
return mgr, eventBus
|
|
}
|
|
|
|
// createTestExecution creates a test execution with proper initialization
|
|
func createTestExecution(id, taskID, sessionID string) *AgentExecution {
|
|
return &AgentExecution{
|
|
ID: id,
|
|
TaskID: taskID,
|
|
SessionID: sessionID,
|
|
Status: v1.AgentStatusRunning,
|
|
StartedAt: time.Now(),
|
|
promptDoneCh: make(chan PromptCompletionSignal, 1),
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_UserMessageChunkNotBufferedAsAssistant(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "<hidden-system-prompt>\nhello",
|
|
Role: "user",
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Hello.",
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
var streamedText string
|
|
for _, event := range eventBus.getStreamEvents() {
|
|
if event.Data != nil && event.Data.Type == "message_streaming" {
|
|
streamedText += event.Data.Text
|
|
}
|
|
}
|
|
if streamedText != "Hello." {
|
|
t.Fatalf("streamed assistant text = %q, want %q", streamedText, "Hello.")
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_CompleteCarriesPromptTurnID(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-turn-id", "task-1", "session-1")
|
|
execution.promptTurnID = "turn-1"
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
generation, err := mgr.executionStore.BeginPrompt(execution.ID)
|
|
if err != nil {
|
|
t.Fatalf("begin prompt: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: streams.EventTypeComplete,
|
|
SessionID: execution.SessionID,
|
|
PromptGeneration: generation,
|
|
})
|
|
|
|
for _, payload := range eventBus.getStreamEvents() {
|
|
if payload.Data != nil && payload.Data.Type == streams.EventTypeComplete {
|
|
if payload.Data.TurnID != "turn-1" {
|
|
t.Fatalf("completion turn_id = %q, want turn-1", payload.Data.TurnID)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("no complete stream event published")
|
|
}
|
|
|
|
// TestHandleAgentEvent_CompleteCarriesActingAgentOfficeIdentity pins that the
|
|
// stream event's AgentProfileID is the acting agent's own office identity
|
|
// (execution.officeProfileID()), not the concrete AgentProfileID the CLI
|
|
// happens to run under. This is what lets office/service attribute a
|
|
// session-bridged comment to the agent that actually ran the turn instead of
|
|
// the task's assignee, without depending on task_sessions.agent_profile_id
|
|
// (which only holds the acting agent when features.officeSessionIdentity is
|
|
// on — off by default in every shipped profile).
|
|
func TestHandleAgentEvent_CompleteCarriesActingAgentOfficeIdentity(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-office-identity", "task-1", "session-1")
|
|
execution.AgentProfileID = "runner-pm"
|
|
execution.OfficeAgentProfileID = "critic"
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
generation, err := mgr.executionStore.BeginPrompt(execution.ID)
|
|
if err != nil {
|
|
t.Fatalf("begin prompt: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: streams.EventTypeComplete,
|
|
SessionID: execution.SessionID,
|
|
PromptGeneration: generation,
|
|
})
|
|
|
|
for _, payload := range eventBus.getStreamEvents() {
|
|
if payload.Data != nil && payload.Data.Type == streams.EventTypeComplete {
|
|
if payload.AgentProfileID != "critic" {
|
|
t.Fatalf("agent_profile_id = %q, want %q (the acting agent, not %q)",
|
|
payload.AgentProfileID, "critic", execution.AgentProfileID)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("no complete stream event published")
|
|
}
|
|
|
|
func TestHandleAgentEvent_RecordsStreamedAssistantTextForResumeContext(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mgr.historyManager = history
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
execution.historyEnabled = true
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "First line.\nSecond line.",
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var recorded strings.Builder
|
|
for _, entry := range entries {
|
|
if entry.Type == "agent_message" {
|
|
recorded.WriteString(entry.Content)
|
|
}
|
|
}
|
|
if recorded.String() != "First line.\nSecond line." {
|
|
t.Fatalf("recorded assistant history = %q, want full streamed message; entries=%+v", recorded.String(), entries)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_ProtocolMessageResumesAcrossToolCall(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "before tool",
|
|
ProtocolMessageID: "acp-message-1",
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-1",
|
|
ToolName: "read_file",
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "after tool",
|
|
ProtocolMessageID: "acp-message-1",
|
|
})
|
|
mgr.flushStreamCoalescer(execution)
|
|
|
|
messageEvents := streamEventsOfType(eventBus, "message_streaming")
|
|
if len(messageEvents) != 2 {
|
|
t.Fatalf("message event count = %d, want 2", len(messageEvents))
|
|
}
|
|
first, second := messageEvents[0].Data, messageEvents[1].Data
|
|
if first.MessageID == "" || second.MessageID != first.MessageID {
|
|
t.Fatalf("Kandev message IDs = (%q, %q), want same non-empty ID", first.MessageID, second.MessageID)
|
|
}
|
|
if first.IsAppend || !second.IsAppend {
|
|
t.Fatalf("append flags = (%t, %t), want (false, true)", first.IsAppend, second.IsAppend)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_InterleavedProtocolMessagesRemainDistinct(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
for _, event := range []agentctl.AgentEvent{
|
|
{Type: "message_chunk", Text: "a1", ProtocolMessageID: "message-a"},
|
|
{Type: "message_chunk", Text: "b1", ProtocolMessageID: "message-b"},
|
|
{Type: "message_chunk", Text: "a2", ProtocolMessageID: "message-a"},
|
|
} {
|
|
mgr.handleAgentEvent(execution, event)
|
|
}
|
|
|
|
messageEvents := streamEventsOfType(eventBus, "message_streaming")
|
|
if len(messageEvents) != 3 {
|
|
t.Fatalf("message event count = %d, want 3", len(messageEvents))
|
|
}
|
|
first, second, third := messageEvents[0].Data, messageEvents[1].Data, messageEvents[2].Data
|
|
if first.Text != "a1" || second.Text != "b1" || third.Text != "a2" {
|
|
t.Fatalf("event order = (%q, %q, %q), want (a1, b1, a2)", first.Text, second.Text, third.Text)
|
|
}
|
|
if first.MessageID == second.MessageID || third.MessageID != first.MessageID {
|
|
t.Fatalf("Kandev message IDs = (%q, %q, %q), want A != B and A stable",
|
|
first.MessageID, second.MessageID, third.MessageID)
|
|
}
|
|
if first.IsAppend || second.IsAppend || !third.IsAppend {
|
|
t.Fatalf("append flags = (%t, %t, %t), want (false, false, true)",
|
|
first.IsAppend, second.IsAppend, third.IsAppend)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_ProtocolThoughtAndMessageIDsUseSeparateNamespaces(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "answer",
|
|
ProtocolMessageID: "shared-source-id",
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "reasoning",
|
|
ReasoningText: "thought",
|
|
ProtocolMessageID: "shared-source-id",
|
|
})
|
|
|
|
messageEvents := streamEventsOfType(eventBus, "message_streaming")
|
|
thinkingEvents := streamEventsOfType(eventBus, "thinking_streaming")
|
|
if len(messageEvents) != 1 || len(thinkingEvents) != 1 {
|
|
t.Fatalf("event counts = message:%d thinking:%d, want 1 each", len(messageEvents), len(thinkingEvents))
|
|
}
|
|
if messageEvents[0].Data.MessageID == thinkingEvents[0].Data.MessageID {
|
|
t.Fatalf("assistant and thought mapped to the same Kandev ID %q", messageEvents[0].Data.MessageID)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_CompleteClearsProtocolMessageCorrelation(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "turn one",
|
|
ProtocolMessageID: "reused-source-id",
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "turn two",
|
|
ProtocolMessageID: "reused-source-id",
|
|
})
|
|
|
|
messageEvents := streamEventsOfType(eventBus, "message_streaming")
|
|
if len(messageEvents) != 2 {
|
|
t.Fatalf("message event count = %d, want 2", len(messageEvents))
|
|
}
|
|
if messageEvents[0].Data.MessageID == messageEvents[1].Data.MessageID {
|
|
t.Fatalf("protocol correlation leaked across completion: Kandev ID %q was reused",
|
|
messageEvents[0].Data.MessageID)
|
|
}
|
|
if messageEvents[1].Data.IsAppend {
|
|
t.Fatal("first chunk after completion was marked as append")
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_LegacyAssistantFlushesBeforeProtocolMessage(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "message_chunk", Text: "legacy first"})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "protocol second",
|
|
ProtocolMessageID: "protocol-message",
|
|
})
|
|
|
|
events := streamEventsOfType(eventBus, "message_streaming")
|
|
if len(events) != 2 {
|
|
t.Fatalf("message event count = %d, want 2", len(events))
|
|
}
|
|
if events[0].Data.Text != "legacy first" || events[1].Data.Text != "protocol second" {
|
|
t.Fatalf("message order = (%q, %q), want legacy then protocol",
|
|
events[0].Data.Text, events[1].Data.Text)
|
|
}
|
|
if events[0].Data.MessageID == events[1].Data.MessageID {
|
|
t.Fatalf("mixed legacy and protocol chunks shared Kandev ID %q", events[0].Data.MessageID)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_ProtocolToLegacyTransitionCreatesBoundary(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
for _, event := range []agentctl.AgentEvent{
|
|
{Type: "message_chunk", Text: "protocol first", ProtocolMessageID: "protocol-message"},
|
|
{Type: "message_chunk", Text: "legacy second\n"},
|
|
{Type: "message_chunk", Text: "protocol third", ProtocolMessageID: "protocol-message"},
|
|
{Type: "message_chunk", Text: "legacy fourth\n"},
|
|
} {
|
|
mgr.handleAgentEvent(execution, event)
|
|
}
|
|
|
|
events := streamEventsOfType(eventBus, "message_streaming")
|
|
if len(events) != 4 {
|
|
t.Fatalf("message event count = %d, want 4", len(events))
|
|
}
|
|
texts := []string{events[0].Data.Text, events[1].Data.Text, events[2].Data.Text, events[3].Data.Text}
|
|
wantTexts := []string{"protocol first", "legacy second\n", "protocol third", "legacy fourth\n"}
|
|
for i := range wantTexts {
|
|
if texts[i] != wantTexts[i] {
|
|
t.Fatalf("event %d text = %q, want %q", i, texts[i], wantTexts[i])
|
|
}
|
|
}
|
|
protocolID := events[0].Data.MessageID
|
|
if events[2].Data.MessageID != protocolID || !events[2].Data.IsAppend {
|
|
t.Fatalf("resumed protocol event = id:%q append:%t, want id:%q append:true",
|
|
events[2].Data.MessageID, events[2].Data.IsAppend, protocolID)
|
|
}
|
|
if events[1].Data.MessageID == protocolID || events[3].Data.MessageID == protocolID {
|
|
t.Fatal("legacy chunks merged into protocol record")
|
|
}
|
|
if events[1].Data.MessageID == events[3].Data.MessageID {
|
|
t.Fatal("legacy chunks on opposite sides of a protocol chunk shared a record")
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_LegacyThinkingFlushesBeforeProtocolThinking(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "reasoning", ReasoningText: "legacy thought"})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "reasoning",
|
|
ReasoningText: "protocol thought",
|
|
ProtocolMessageID: "thought-message",
|
|
})
|
|
|
|
events := streamEventsOfType(eventBus, "thinking_streaming")
|
|
if len(events) != 2 {
|
|
t.Fatalf("thinking event count = %d, want 2", len(events))
|
|
}
|
|
if events[0].Data.Text != "legacy thought" || events[1].Data.Text != "protocol thought" {
|
|
t.Fatalf("thinking order = (%q, %q), want legacy then protocol",
|
|
events[0].Data.Text, events[1].Data.Text)
|
|
}
|
|
if events[0].Data.MessageID == events[1].Data.MessageID {
|
|
t.Fatalf("mixed legacy and protocol thinking shared Kandev ID %q", events[0].Data.MessageID)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_ProtocolThinkingBurstIsCoalescedBeforeCompletion(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
const chunkCount = 20
|
|
for index := 0; index < chunkCount; index++ {
|
|
text := "b"
|
|
if index == 0 {
|
|
text = "a"
|
|
}
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "reasoning",
|
|
ReasoningText: text,
|
|
ProtocolMessageID: "burst-thinking",
|
|
})
|
|
}
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
var thinkingEvents []AgentStreamEventPayload
|
|
var streamed strings.Builder
|
|
for _, event := range eventBus.getStreamEvents() {
|
|
if event.Data == nil || event.Data.Type != "thinking_streaming" {
|
|
continue
|
|
}
|
|
thinkingEvents = append(thinkingEvents, event)
|
|
streamed.WriteString(event.Data.Text)
|
|
}
|
|
if got := len(thinkingEvents); got > 2 {
|
|
t.Fatalf("thinking publication count = %d, want at most 2 for %d chunks", got, chunkCount)
|
|
}
|
|
if got := streamed.String(); got != "abbbbbbbbbbbbbbbbbbb" {
|
|
t.Fatalf("coalesced thinking content = %q, want exact burst content", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_LegacyNewlineBurstIsCoalescedBeforeCompletion(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
const chunkCount = 20
|
|
var want strings.Builder
|
|
for index := 0; index < chunkCount; index++ {
|
|
text := "legacy chunk\n"
|
|
want.WriteString(text)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: text,
|
|
})
|
|
}
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
messageEvents := streamEventsOfType(eventBus, "message_streaming")
|
|
if got := len(messageEvents); got > 2 {
|
|
t.Fatalf("message publication count = %d, want at most 2 for %d ID-less chunks", got, chunkCount)
|
|
}
|
|
var streamed strings.Builder
|
|
for _, event := range messageEvents {
|
|
streamed.WriteString(event.Data.Text)
|
|
}
|
|
if got := streamed.String(); got != want.String() {
|
|
t.Fatalf("coalesced legacy content = %q, want exact burst content", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_ProtocolAssistantHistoryPersistedOnceInWireOrder(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
mgr.historyManager = history
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
execution.historyEnabled = true
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
for _, event := range []agentctl.AgentEvent{
|
|
{Type: "message_chunk", Text: "first", ProtocolMessageID: "message-a"},
|
|
{Type: "message_chunk", Text: " second", ProtocolMessageID: "message-b"},
|
|
{Type: "message_chunk", Text: " third", ProtocolMessageID: "message-a"},
|
|
} {
|
|
mgr.handleAgentEvent(execution, event)
|
|
}
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("read history: %v", err)
|
|
}
|
|
var agentEntries []HistoryEntry
|
|
for _, entry := range entries {
|
|
if entry.Type == "agent_message" {
|
|
agentEntries = append(agentEntries, entry)
|
|
}
|
|
}
|
|
if len(agentEntries) != 1 {
|
|
t.Fatalf("agent history entry count = %d, want 1", len(agentEntries))
|
|
}
|
|
if agentEntries[0].Content != "first second third" {
|
|
t.Fatalf("agent history content = %q, want wire-order transcript", agentEntries[0].Content)
|
|
}
|
|
execution.messageMu.Lock()
|
|
historyBufferLen := execution.assistantHistoryBuffer.Len()
|
|
execution.messageMu.Unlock()
|
|
if historyBufferLen != 0 {
|
|
t.Fatalf("assistant history accumulator length = %d after completion, want 0", historyBufferLen)
|
|
}
|
|
}
|
|
|
|
func TestStreamDisconnect_PersistsPartialAssistantHistoryExactlyOnce(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
mgr.historyManager = history
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
execution.historyEnabled = true
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "partial response",
|
|
ProtocolMessageID: "message-a",
|
|
})
|
|
mgr.handleStreamDisconnect(execution, errors.New("connection lost"), 0)
|
|
|
|
// A later prompt or reset must not discard or duplicate the segment.
|
|
flushAssistantHistory(execution, history, mgr.logger)
|
|
execution.messageMu.Lock()
|
|
execution.resetStreamingStateLocked()
|
|
execution.messageMu.Unlock()
|
|
mgr.flushAssistantHistory(execution)
|
|
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("read history: %v", err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Type != "agent_message" ||
|
|
entries[0].Content != "partial response" {
|
|
t.Fatalf("disconnect history = %+v, want one partial assistant message", entries)
|
|
}
|
|
|
|
messageEvents := streamEventsOfType(eventBus, "message_streaming")
|
|
if len(messageEvents) != 1 || messageEvents[0].Data.Text != "partial response" {
|
|
t.Fatalf("visible messages = %+v, want the original chunk exactly once", messageEvents)
|
|
}
|
|
}
|
|
|
|
func TestPromptResetWinsDisconnectRaceWithoutDroppingHistory(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
mgr.historyManager = history
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
execution.historyEnabled = true
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "response before disconnect callback",
|
|
})
|
|
|
|
// connectUpdatesStream signals promptDoneCh before invoking the disconnect
|
|
// callback. Model a next prompt claiming the buffer first.
|
|
flushAssistantHistory(execution, history, mgr.logger)
|
|
execution.messageMu.Lock()
|
|
execution.resetStreamingStateLocked()
|
|
execution.messageMu.Unlock()
|
|
mgr.handleStreamDisconnect(execution, errors.New("connection lost"), 0)
|
|
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("read history: %v", err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Content != "response before disconnect callback" {
|
|
t.Fatalf("disconnect-race history = %+v, want one partial assistant message", entries)
|
|
}
|
|
}
|
|
|
|
func TestDelayedDisconnectCannotAffectReplacementGeneration(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
mgr.historyManager = history
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
execution.historyEnabled = true
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
originalGeneration, err := mgr.executionStore.BeginPrompt(execution.ID)
|
|
if err != nil {
|
|
t.Fatalf("begin original prompt: %v", err)
|
|
}
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "message_chunk", Text: "old partial"})
|
|
|
|
callbackReached := make(chan struct{})
|
|
releaseCallback := make(chan struct{})
|
|
callbackDone := make(chan struct{})
|
|
streamManager := NewStreamManager(mgr.logger, StreamCallbacks{
|
|
OnStreamDisconnect: func(exec *AgentExecution, disconnectErr error, generation uint64) {
|
|
close(callbackReached)
|
|
<-releaseCallback
|
|
mgr.handleStreamDisconnect(exec, disconnectErr, generation)
|
|
close(callbackDone)
|
|
},
|
|
}, nil, nil)
|
|
go streamManager.handleUpdatesDisconnect(execution, errors.New("old stream lost"))
|
|
|
|
<-callbackReached
|
|
signal := <-execution.promptDoneCh
|
|
if signal.PromptGeneration != originalGeneration {
|
|
t.Fatalf("disconnect signal generation = %d, want %d", signal.PromptGeneration, originalGeneration)
|
|
}
|
|
|
|
replacementGeneration, err := mgr.executionStore.BeginPrompt(execution.ID)
|
|
if err != nil {
|
|
t.Fatalf("begin replacement prompt: %v", err)
|
|
}
|
|
if replacementGeneration == originalGeneration {
|
|
t.Fatal("replacement prompt did not advance generation")
|
|
}
|
|
resetStreamingStateWithHistory(execution, history, mgr.logger)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "message_chunk", Text: "replacement text"})
|
|
|
|
close(releaseCallback)
|
|
<-callbackDone
|
|
|
|
if execution.Status != v1.AgentStatusRunning {
|
|
t.Fatalf("replacement status = %s, want %s", execution.Status, v1.AgentStatusRunning)
|
|
}
|
|
execution.messageMu.Lock()
|
|
assistantHistory := execution.assistantHistoryBuffer.String()
|
|
messageBuffer := execution.messageBuffer.String()
|
|
execution.messageMu.Unlock()
|
|
if assistantHistory != "replacement text" || messageBuffer != "replacement text" {
|
|
t.Fatalf("replacement buffers changed: history=%q message=%q", assistantHistory, messageBuffer)
|
|
}
|
|
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("read history: %v", err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Content != "old partial" {
|
|
t.Fatalf("history = %+v, want only the original partial response", entries)
|
|
}
|
|
for _, published := range eventBus.PublishedEvents {
|
|
if published.Subject == events.AgentctlError {
|
|
t.Fatal("superseded disconnect published AgentctlError")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResetStreamingStateWithHistory_HasNoDrainResetGap(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
execution.historyEnabled = true
|
|
execution.assistantHistoryBuffer.WriteString("old partial")
|
|
execution.messageBuffer.WriteString("old partial")
|
|
|
|
// Hold persistence so the test can force a chunk to arrive after history
|
|
// detaches. With split drain/reset locks, that chunk was accepted in the
|
|
// gap and then erased by reset. The atomic helper resets before blocking
|
|
// on history I/O, so the late chunk belongs to the post-reset buffer.
|
|
history.mu.Lock()
|
|
historyLocked := true
|
|
defer func() {
|
|
if historyLocked {
|
|
history.mu.Unlock()
|
|
}
|
|
}()
|
|
resetDone := make(chan struct{})
|
|
go func() {
|
|
resetStreamingStateWithHistory(execution, history, mgr.logger)
|
|
close(resetDone)
|
|
}()
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
execution.messageMu.Lock()
|
|
detached := execution.assistantHistoryBuffer.Len() == 0
|
|
execution.messageMu.Unlock()
|
|
if detached {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("assistant history did not detach")
|
|
}
|
|
runtime.Gosched()
|
|
}
|
|
mgr.handleMessageChunkEvent(execution, agentctl.AgentEvent{Type: "message_chunk", Text: "late chunk"})
|
|
|
|
history.mu.Unlock()
|
|
historyLocked = false
|
|
<-resetDone
|
|
|
|
execution.messageMu.Lock()
|
|
assistantHistory := execution.assistantHistoryBuffer.String()
|
|
messageBuffer := execution.messageBuffer.String()
|
|
execution.messageMu.Unlock()
|
|
if assistantHistory != "late chunk" || messageBuffer != "late chunk" {
|
|
t.Fatalf("late chunk lost across reset: history=%q message=%q", assistantHistory, messageBuffer)
|
|
}
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("read history: %v", err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Content != "old partial" {
|
|
t.Fatalf("detached history = %+v, want only old partial", entries)
|
|
}
|
|
}
|
|
|
|
func TestStreamDisconnect_DiscardsHistoryWhenRecordingUnavailable(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
historyManager bool
|
|
historyEnabled bool
|
|
sessionID string
|
|
}{
|
|
{name: "history manager unavailable", historyEnabled: true, sessionID: "session-1"},
|
|
{name: "history disabled", historyManager: true, sessionID: "session-1"},
|
|
{name: "session unavailable", historyManager: true, historyEnabled: true},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
mgr.historyManager = nil
|
|
if test.historyManager {
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
mgr.historyManager = history
|
|
}
|
|
execution := createTestExecution("exec-1", "task-1", test.sessionID)
|
|
execution.historyEnabled = test.historyEnabled
|
|
execution.assistantHistoryBuffer.WriteString("discard me")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleStreamDisconnect(execution, errors.New("connection lost"), 0)
|
|
|
|
execution.messageMu.Lock()
|
|
buffered := execution.assistantHistoryBuffer.Len()
|
|
execution.messageMu.Unlock()
|
|
if buffered != 0 {
|
|
t.Fatalf("assistant history buffer length = %d, want discarded", buffered)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_ProtocolAssistantAndToolHistoryFollowWireOrder(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
mgr.historyManager = history
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
execution.historyEnabled = true
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
for _, event := range []agentctl.AgentEvent{
|
|
{Type: "message_chunk", Text: "before tool", ProtocolMessageID: "message-a"},
|
|
{Type: "tool_call", ToolCallID: "tool-1", ToolName: "read_file", ToolStatus: "started"},
|
|
{Type: "message_chunk", Text: "during tool", ProtocolMessageID: "message-a"},
|
|
{Type: "tool_update", ToolCallID: "tool-1", ToolName: "read_file", ToolStatus: toolStatusComplete},
|
|
{Type: "message_chunk", Text: "after result", ProtocolMessageID: "message-a"},
|
|
{Type: "complete"},
|
|
} {
|
|
mgr.handleAgentEvent(execution, event)
|
|
}
|
|
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("read history: %v", err)
|
|
}
|
|
if len(entries) != 5 {
|
|
t.Fatalf("history entry count = %d, want 5: %+v", len(entries), entries)
|
|
}
|
|
wantTypes := []string{"agent_message", historyEntryTypeToolCall, "agent_message", "tool_result", "agent_message"}
|
|
wantContent := []string{"before tool", "", "during tool", "", "after result"}
|
|
for i := range wantTypes {
|
|
if entries[i].Type != wantTypes[i] || entries[i].Content != wantContent[i] {
|
|
t.Fatalf("entry %d = type:%q content:%q, want type:%q content:%q",
|
|
i, entries[i].Type, entries[i].Content, wantTypes[i], wantContent[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_MixedAssistantHistoryPreservesAllTextAroundTool(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
mgr.historyManager = history
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
execution.historyEnabled = true
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
for _, event := range []agentctl.AgentEvent{
|
|
{Type: "message_chunk", Text: "legacy before "},
|
|
{Type: "message_chunk", Text: "protocol before", ProtocolMessageID: "message-a"},
|
|
{Type: "tool_call", ToolCallID: "tool-1", ToolName: "read_file"},
|
|
{Type: "message_chunk", Text: "legacy after "},
|
|
{Type: "message_chunk", Text: "protocol after", ProtocolMessageID: "message-a"},
|
|
{Type: "complete"},
|
|
} {
|
|
mgr.handleAgentEvent(execution, event)
|
|
}
|
|
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("read history: %v", err)
|
|
}
|
|
if len(entries) != 3 {
|
|
t.Fatalf("history entry count = %d, want 3: %+v", len(entries), entries)
|
|
}
|
|
if entries[0].Type != "agent_message" || entries[0].Content != "legacy before protocol before" {
|
|
t.Fatalf("first history segment = %+v, want complete mixed pre-tool text", entries[0])
|
|
}
|
|
if entries[1].Type != historyEntryTypeToolCall {
|
|
t.Fatalf("second history entry type = %q, want %q", entries[1].Type, historyEntryTypeToolCall)
|
|
}
|
|
if entries[2].Type != "agent_message" || entries[2].Content != "legacy after protocol after" {
|
|
t.Fatalf("final history segment = %+v, want complete mixed post-tool text", entries[2])
|
|
}
|
|
|
|
messageEvents := streamEventsOfType(eventBus, "message_streaming")
|
|
if len(messageEvents) != 4 {
|
|
t.Fatalf("visible message event count = %d, want 4", len(messageEvents))
|
|
}
|
|
wantVisible := []string{"legacy before", "protocol before", "legacy after", "protocol after"}
|
|
for i, want := range wantVisible {
|
|
if messageEvents[i].Data.Text != want {
|
|
t.Fatalf("visible event %d text = %q, want %q", i, messageEvents[i].Data.Text, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_HistoryDisabledDoesNotPersistOrLeakAssistantText(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
mgr.historyManager = history
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
for _, event := range []agentctl.AgentEvent{
|
|
{Type: "message_chunk", Text: "legacy"},
|
|
{Type: "message_chunk", Text: " protocol", ProtocolMessageID: "message-a"},
|
|
{Type: "tool_call", ToolCallID: "tool-1", ToolName: "read_file"},
|
|
{Type: "message_chunk", Text: "after", ProtocolMessageID: "message-a"},
|
|
{Type: "complete"},
|
|
} {
|
|
mgr.handleAgentEvent(execution, event)
|
|
}
|
|
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("read history: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("history-disabled execution persisted entries: %+v", entries)
|
|
}
|
|
execution.messageMu.Lock()
|
|
historyBufferLen := execution.assistantHistoryBuffer.Len()
|
|
execution.messageMu.Unlock()
|
|
if historyBufferLen != 0 {
|
|
t.Fatalf("assistant history accumulator length = %d after completion, want 0", historyBufferLen)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_EmptyHistoryBoundariesDoNotDuplicateAssistant(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("create history manager: %v", err)
|
|
}
|
|
mgr.historyManager = history
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
execution.historyEnabled = true
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
for _, event := range []agentctl.AgentEvent{
|
|
{Type: "message_chunk", Text: "only once", ProtocolMessageID: "message-a"},
|
|
{Type: "tool_call", ToolCallID: "tool-1", ToolName: "read_file"},
|
|
{Type: "tool_update", ToolCallID: "tool-1", ToolName: "read_file", ToolStatus: toolStatusComplete},
|
|
{Type: "complete"},
|
|
{Type: "complete"},
|
|
} {
|
|
mgr.handleAgentEvent(execution, event)
|
|
}
|
|
|
|
entries, err := history.ReadHistory(execution.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("read history: %v", err)
|
|
}
|
|
if len(entries) != 3 {
|
|
t.Fatalf("history entry count = %d, want assistant/tool/result only: %+v", len(entries), entries)
|
|
}
|
|
if entries[0].Type != "agent_message" || entries[0].Content != "only once" {
|
|
t.Fatalf("assistant history entry = %+v, want one exact segment", entries[0])
|
|
}
|
|
if entries[1].Type != historyEntryTypeToolCall || entries[2].Type != "tool_result" {
|
|
t.Fatalf("history boundary entries = (%q, %q), want tool_call/tool_result",
|
|
entries[1].Type, entries[2].Type)
|
|
}
|
|
}
|
|
|
|
func streamEventsOfType(eventBus *MockEventBusWithTracking, eventType string) []AgentStreamEventPayload {
|
|
var matching []AgentStreamEventPayload
|
|
for _, event := range eventBus.getStreamEvents() {
|
|
if event.Data != nil && event.Data.Type == eventType {
|
|
matching = append(matching, event)
|
|
}
|
|
}
|
|
return matching
|
|
}
|
|
|
|
// TestHandleAgentEvent_StreamingThenComplete tests the normal flow:
|
|
// message_chunk events followed by complete event - should NOT create duplicate
|
|
func TestHandleAgentEvent_StreamingThenComplete(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Simulate streaming chunks with newlines (which trigger publishing)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Hello, world!\n",
|
|
})
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "This is a test.\n",
|
|
})
|
|
|
|
// Now send complete event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
// Verify: streaming was used, so complete should NOT have text
|
|
events := eventBus.getStreamEvents()
|
|
|
|
// Count message_streaming events (creates/appends)
|
|
var messageStreamingEvents []AgentStreamEventPayload
|
|
var completeEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil {
|
|
switch e.Data.Type {
|
|
case "message_streaming":
|
|
messageStreamingEvents = append(messageStreamingEvents, e)
|
|
case "complete":
|
|
completeEvents = append(completeEvents, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Should have streaming messages
|
|
if len(messageStreamingEvents) == 0 {
|
|
t.Error("expected message_streaming events, got none")
|
|
}
|
|
|
|
// Should have exactly one complete event
|
|
if len(completeEvents) != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", len(completeEvents))
|
|
}
|
|
|
|
// The complete event should NOT have text (streaming handled it via buffer)
|
|
if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" {
|
|
t.Errorf("complete event should not have text when streaming was used, got %q", completeEvents[0].Data.Text)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_StreamingThenToolCallThenComplete tests the scenario that could cause duplicates:
|
|
// message_chunk → tool_call (clears currentMessageID) → complete
|
|
// This verifies that the buffer is properly flushed on complete after tool calls
|
|
func TestHandleAgentEvent_StreamingThenToolCallThenComplete(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Simulate streaming chunks
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Let me check that for you.\n",
|
|
})
|
|
|
|
// Verify currentMessageID is set after message_chunk
|
|
execution.messageMu.Lock()
|
|
msgIDBeforeToolCall := execution.currentMessageID
|
|
execution.messageMu.Unlock()
|
|
|
|
if msgIDBeforeToolCall == "" {
|
|
t.Error("currentMessageID should be set after message_chunk")
|
|
}
|
|
|
|
// Tool call - this flushes buffer and clears currentMessageID
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-1",
|
|
ToolName: "read_file",
|
|
})
|
|
|
|
// After tool call, currentMessageID should be cleared
|
|
execution.messageMu.Lock()
|
|
msgIDAfterToolCall := execution.currentMessageID
|
|
execution.messageMu.Unlock()
|
|
|
|
if msgIDAfterToolCall != "" {
|
|
t.Errorf("currentMessageID should be cleared after tool_call, got %q", msgIDAfterToolCall)
|
|
}
|
|
|
|
// Now complete - this should flush the buffer
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
// Find the complete event
|
|
var completeEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "complete" {
|
|
completeEvents = append(completeEvents, e)
|
|
}
|
|
}
|
|
|
|
if len(completeEvents) != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", len(completeEvents))
|
|
}
|
|
|
|
// The complete event should NOT have text (streaming was used)
|
|
if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" {
|
|
t.Errorf("complete event should not have text when streaming was used (even after tool_call), got %q",
|
|
completeEvents[0].Data.Text)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_SubagentToolCallDoesNotSplitStreaming is the regression test
|
|
// for streaming messages being shattered into multiple DB rows: a subagent (Task tool)
|
|
// streams its internal tool calls (tagged with ParentToolCallID) on the same session
|
|
// while the parent agent is still streaming text. Those tool calls must NOT flush the
|
|
// message buffer — otherwise every subagent tool call starts a new message row,
|
|
// splitting the parent's message mid-sentence and breaking markdown across rows.
|
|
func TestHandleAgentEvent_SubagentToolCallDoesNotSplitStreaming(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
// Parent agent starts streaming its message
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Here's the CI picture\n",
|
|
})
|
|
|
|
execution.messageMu.Lock()
|
|
msgIDBefore := execution.currentMessageID
|
|
execution.messageMu.Unlock()
|
|
if msgIDBefore == "" {
|
|
t.Fatal("currentMessageID should be set after message_chunk")
|
|
}
|
|
|
|
// A subagent's internal tool call arrives mid-stream (ParentToolCallID set)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "subagent-tool-1",
|
|
ParentToolCallID: "parent-task-tool",
|
|
ToolName: "execute",
|
|
})
|
|
|
|
// The parent's streaming message must survive the subagent tool call
|
|
execution.messageMu.Lock()
|
|
msgIDAfter := execution.currentMessageID
|
|
execution.messageMu.Unlock()
|
|
if msgIDAfter != msgIDBefore {
|
|
t.Errorf("subagent tool_call must not close the streaming message: message ID changed from %q to %q",
|
|
msgIDBefore, msgIDAfter)
|
|
}
|
|
|
|
// More parent text — must APPEND to the same message, not create a new one
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "on the new PR.\n",
|
|
})
|
|
mgr.flushStreamCoalescer(execution)
|
|
|
|
var streamingEvents []AgentStreamEventPayload
|
|
for _, e := range eventBus.getStreamEvents() {
|
|
if e.Data != nil && e.Data.Type == "message_streaming" {
|
|
streamingEvents = append(streamingEvents, e)
|
|
}
|
|
}
|
|
if len(streamingEvents) < 2 {
|
|
t.Fatalf("expected at least 2 message_streaming events, got %d", len(streamingEvents))
|
|
}
|
|
last := streamingEvents[len(streamingEvents)-1]
|
|
if !last.Data.IsAppend {
|
|
t.Error("text after a subagent tool_call should append to the existing message, got IsAppend=false")
|
|
}
|
|
if last.Data.MessageID != msgIDBefore {
|
|
t.Errorf("text after a subagent tool_call should keep message ID %q, got %q",
|
|
msgIDBefore, last.Data.MessageID)
|
|
}
|
|
|
|
// A top-level tool call (no ParentToolCallID) must still flush as before
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-1",
|
|
ToolName: "read_file",
|
|
})
|
|
|
|
execution.messageMu.Lock()
|
|
msgIDAfterTopLevel := execution.currentMessageID
|
|
execution.messageMu.Unlock()
|
|
if msgIDAfterTopLevel != "" {
|
|
t.Errorf("currentMessageID should be cleared after a top-level tool_call, got %q", msgIDAfterTopLevel)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_CompleteWithoutStreaming verifies that complete events are
|
|
// properly handled when no streaming was used (buffer is empty).
|
|
// All adapters now send text via message_chunk events, so this tests the empty buffer case.
|
|
func TestHandleAgentEvent_CompleteWithoutStreaming(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Complete event without any prior streaming (e.g., agent did only tool calls)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
var completeEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "complete" {
|
|
completeEvents = append(completeEvents, e)
|
|
}
|
|
}
|
|
|
|
if len(completeEvents) != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", len(completeEvents))
|
|
}
|
|
|
|
// Complete event should be published successfully
|
|
// The buffer was empty, so no message_streaming events should be generated
|
|
var streamingEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "message_streaming" {
|
|
streamingEvents = append(streamingEvents, e)
|
|
}
|
|
}
|
|
|
|
if len(streamingEvents) != 0 {
|
|
t.Errorf("expected 0 message_streaming events when buffer is empty, got %d", len(streamingEvents))
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_CompleteWithBufferedText verifies that buffered text
|
|
// without streaming is emitted as a final streaming event on complete.
|
|
func TestHandleAgentEvent_CompleteWithBufferedText(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Buffer text without newlines (no streaming event should be emitted)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Final message without newline",
|
|
})
|
|
|
|
// Complete event should flush buffer into a streaming event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
var completeEvents []AgentStreamEventPayload
|
|
var streamingEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil {
|
|
switch e.Data.Type {
|
|
case "complete":
|
|
completeEvents = append(completeEvents, e)
|
|
case "message_streaming":
|
|
streamingEvents = append(streamingEvents, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(completeEvents) != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", len(completeEvents))
|
|
}
|
|
|
|
if len(completeEvents) > 0 && completeEvents[0].Data.Text != "" {
|
|
t.Errorf("expected complete event to have empty text, got %q", completeEvents[0].Data.Text)
|
|
}
|
|
|
|
if len(streamingEvents) != 1 {
|
|
t.Errorf("expected 1 message_streaming event when no newlines, got %d", len(streamingEvents))
|
|
} else if streamingEvents[0].Data.Text != "Final message without newline" {
|
|
t.Errorf("expected streaming event to carry buffered text, got %q", streamingEvents[0].Data.Text)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_CompleteThenMessageChunk tests the scenario where
|
|
// message_chunk arrives after complete. This documents the behavior when
|
|
// an adapter incorrectly sends text after the turn has completed.
|
|
// With the new architecture, adapters should NOT send message_chunk after complete.
|
|
func TestHandleAgentEvent_CompleteThenMessageChunk(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// First, simulate normal streaming during the turn
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Processing your request...\n",
|
|
})
|
|
|
|
// Complete event arrives - this flushes the buffer
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
// Now message_chunk arrives AFTER complete
|
|
// This shouldn't happen with properly implemented adapters,
|
|
// but we document the behavior: it creates a new message
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Done!\n",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
// Count message_streaming events
|
|
var messageStreamingEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "message_streaming" {
|
|
messageStreamingEvents = append(messageStreamingEvents, e)
|
|
}
|
|
}
|
|
|
|
// Document the behavior: message_chunk after complete starts a new message
|
|
t.Logf("Got %d message_streaming events", len(messageStreamingEvents))
|
|
for i, e := range messageStreamingEvents {
|
|
t.Logf(" Event %d: MessageID=%s, IsAppend=%v, Text=%q",
|
|
i, e.Data.MessageID, e.Data.IsAppend, e.Data.Text)
|
|
}
|
|
|
|
// The second message_chunk (after complete) should start a NEW message
|
|
// since currentMessageID was cleared by the complete event
|
|
if len(messageStreamingEvents) >= 2 {
|
|
lastEvent := messageStreamingEvents[len(messageStreamingEvents)-1]
|
|
if !lastEvent.Data.IsAppend {
|
|
t.Log("Expected behavior: message_chunk after complete creates a new message")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_MultipleToolCalls tests streaming → tool → streaming → tool → complete
|
|
func TestHandleAgentEvent_MultipleToolCalls(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Message before first tool
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Let me read the file.\n",
|
|
})
|
|
|
|
// First tool call
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-1",
|
|
ToolName: "read_file",
|
|
})
|
|
|
|
// Tool update (complete)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_update",
|
|
ToolCallID: "tool-1",
|
|
ToolStatus: "complete",
|
|
})
|
|
|
|
// Message after first tool
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Now let me modify it.\n",
|
|
})
|
|
|
|
// Second tool call
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-2",
|
|
ToolName: "write_file",
|
|
})
|
|
|
|
// Tool update (complete)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_update",
|
|
ToolCallID: "tool-2",
|
|
ToolStatus: "complete",
|
|
})
|
|
|
|
// Final message
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "Done with both tasks!\n",
|
|
})
|
|
|
|
// Complete
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
|
|
// Count different event types
|
|
var messageStreamingCount, toolCallCount, completeCount int
|
|
for _, e := range events {
|
|
if e.Data != nil {
|
|
switch e.Data.Type {
|
|
case "message_streaming":
|
|
messageStreamingCount++
|
|
case "tool_call":
|
|
toolCallCount++
|
|
case "complete":
|
|
completeCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Logf("Events: message_streaming=%d, tool_call=%d, complete=%d",
|
|
messageStreamingCount, toolCallCount, completeCount)
|
|
|
|
// Should have multiple streaming messages (one per "segment" before tool calls)
|
|
if messageStreamingCount < 3 {
|
|
t.Errorf("expected at least 3 message_streaming events for 3 message segments, got %d", messageStreamingCount)
|
|
}
|
|
|
|
if toolCallCount != 2 {
|
|
t.Errorf("expected 2 tool_call events, got %d", toolCallCount)
|
|
}
|
|
|
|
if completeCount != 1 {
|
|
t.Errorf("expected 1 complete event, got %d", completeCount)
|
|
}
|
|
|
|
// Find the complete event and verify it has no text
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "complete" {
|
|
if e.Data.Text != "" {
|
|
t.Errorf("complete event should not have text when streaming was used, got %q", e.Data.Text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_CompleteSignalsPromptDoneCh verifies that the complete event
|
|
// signals the promptDoneCh channel with the correct stop reason.
|
|
func TestHandleAgentEvent_CompleteSignalsPromptDoneCh(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Send a normal complete event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
// promptDoneCh should have a signal
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
if signal.IsError {
|
|
t.Error("expected non-error signal for normal complete")
|
|
}
|
|
if signal.StopReason != "end_turn" {
|
|
t.Errorf("expected stop_reason 'end_turn', got %q", signal.StopReason)
|
|
}
|
|
default:
|
|
t.Error("expected signal on promptDoneCh, got none")
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh verifies that an error completion
|
|
// signals the promptDoneCh channel with IsError=true and the error message.
|
|
func TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Send an error complete event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
Error: "something went wrong",
|
|
Data: map[string]interface{}{"is_error": true},
|
|
})
|
|
|
|
// promptDoneCh should have an error signal
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
if !signal.IsError {
|
|
t.Error("expected error signal for error complete")
|
|
}
|
|
if signal.StopReason != "error" {
|
|
t.Errorf("expected stop_reason 'error', got %q", signal.StopReason)
|
|
}
|
|
if signal.Error != "something went wrong" {
|
|
t.Errorf("expected error 'something went wrong', got %q", signal.Error)
|
|
}
|
|
default:
|
|
t.Error("expected signal on promptDoneCh, got none")
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_DuplicateErrorCannotReplaceProviderError(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-duplicate-error", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
generation, err := mgr.executionStore.BeginPrompt(execution.ID)
|
|
if err != nil {
|
|
t.Fatalf("begin prompt: %v", err)
|
|
}
|
|
first := &streams.ProviderError{
|
|
Source: streams.ProviderErrorSourceOpenCodeStderr,
|
|
ProviderID: "opencode-go",
|
|
ModelID: "kimi-k3",
|
|
Message: "5-hour usage limit reached",
|
|
OccurredAt: time.Now().UTC(),
|
|
}
|
|
second := *first
|
|
second.Message = "a different provider failure"
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
PromptGeneration: generation,
|
|
ProviderError: first,
|
|
Error: first.Message,
|
|
Data: map[string]interface{}{"is_error": true},
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
PromptGeneration: generation,
|
|
ProviderError: &second,
|
|
Error: second.Message,
|
|
Data: map[string]interface{}{"is_error": true},
|
|
})
|
|
if execution.ProviderError == nil || execution.ProviderError.Message != first.Message {
|
|
t.Fatalf("provider error was replaced: %+v", execution.ProviderError)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_UpdatesLastActivityAt verifies that every agent event
|
|
// updates the lastActivityAt timestamp for stall detection.
|
|
func TestHandleAgentEvent_UpdatesLastActivityAt(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Set lastActivityAt to a known old time
|
|
oldTime := time.Now().Add(-10 * time.Minute)
|
|
execution.lastActivityAtMu.Lock()
|
|
execution.lastActivityAt = oldTime
|
|
execution.lastActivityAtMu.Unlock()
|
|
|
|
// Send any event
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "hello\n",
|
|
})
|
|
|
|
// lastActivityAt should be updated to approximately now
|
|
execution.lastActivityAtMu.Lock()
|
|
elapsed := time.Since(execution.lastActivityAt)
|
|
execution.lastActivityAtMu.Unlock()
|
|
|
|
if elapsed > 1*time.Second {
|
|
t.Errorf("lastActivityAt not updated: elapsed %v since last event", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestRecordActivity_MetadataDoesNotMarkPromptStarted(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-metadata", "task-1", "session-1")
|
|
execution.agentEventSincePrompt = false
|
|
execution.lastActivityAt = time.Now().Add(-time.Minute)
|
|
|
|
mgr.recordActivity(execution, agentctl.AgentEvent{Type: "session_models"})
|
|
|
|
lastActivity, agentEventSeen, epoch := execution.promptActivitySnapshot()
|
|
if time.Since(lastActivity) > time.Second {
|
|
t.Fatalf("metadata event did not refresh last activity: %v ago", time.Since(lastActivity))
|
|
}
|
|
if agentEventSeen {
|
|
t.Fatal("metadata event marked the prompt as started")
|
|
}
|
|
if epoch != 0 {
|
|
t.Fatalf("metadata event advanced activity epoch to %d, want 0", epoch)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_TracksActiveTopLevelTool(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "top-level-tool",
|
|
ToolName: "shell",
|
|
ToolTitle: "Start dev server",
|
|
ToolStatus: "in_progress",
|
|
})
|
|
|
|
if got := execution.activeToolSnapshot(); got == nil ||
|
|
got.ToolCallID != "top-level-tool" || got.Title != "Start dev server" {
|
|
t.Fatalf("active top-level tool = %#v, want Start dev server", got)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "subagent-tool",
|
|
ToolName: "shell",
|
|
ToolTitle: "Run subagent command",
|
|
ParentToolCallID: "top-level-tool",
|
|
ToolStatus: "in_progress",
|
|
})
|
|
|
|
if got := execution.activeToolSnapshot(); got == nil || got.ToolCallID != "top-level-tool" {
|
|
t.Fatalf("subagent tool replaced top-level tool: %#v", got)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_update",
|
|
ToolCallID: "top-level-tool",
|
|
ToolStatus: toolStatusComplete,
|
|
})
|
|
|
|
if got := execution.activeToolSnapshot(); got != nil {
|
|
t.Fatalf("terminal update left active top-level tool: %#v", got)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_PromptDoneChDoesNotBlockWhenFull verifies that signaling
|
|
// promptDoneCh with a full channel (no receiver) doesn't block the event handler.
|
|
func TestHandleAgentEvent_PromptDoneChDoesNotBlockWhenFull(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Pre-fill the channel
|
|
execution.promptDoneCh <- PromptCompletionSignal{StopReason: "stale"}
|
|
|
|
// Send complete event — should not block
|
|
done := make(chan struct{})
|
|
go func() {
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Good — didn't block
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("handleAgentEvent blocked when promptDoneCh was full")
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_DelayedCompleteCannotFinishReplacementPrompt(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
if _, err := mgr.executionStore.BeginPrompt(execution.ID); err != nil {
|
|
t.Fatalf("begin original prompt: %v", err)
|
|
}
|
|
if _, err := mgr.executionStore.BeginPrompt(execution.ID); err != nil {
|
|
t.Fatalf("begin replacement prompt: %v", err)
|
|
}
|
|
|
|
execution.messageMu.Lock()
|
|
execution.messageBuffer.WriteString("replacement output")
|
|
execution.currentMessageID = "replacement-message"
|
|
execution.messageMu.Unlock()
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
SessionID: execution.SessionID,
|
|
PromptGeneration: 1,
|
|
})
|
|
|
|
if execution.Status != v1.AgentStatusRunning {
|
|
t.Fatalf("replacement status = %s, want %s", execution.Status, v1.AgentStatusRunning)
|
|
}
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
t.Fatalf("delayed completion signaled replacement prompt: %+v", signal)
|
|
default:
|
|
}
|
|
execution.messageMu.Lock()
|
|
buffer := execution.messageBuffer.String()
|
|
messageID := execution.currentMessageID
|
|
execution.messageMu.Unlock()
|
|
if buffer != "replacement output" || messageID != "replacement-message" {
|
|
t.Fatalf("replacement stream state changed: buffer=%q message_id=%q", buffer, messageID)
|
|
}
|
|
for _, published := range eventBus.PublishedEvents {
|
|
if published.Subject == events.AgentReady {
|
|
t.Fatal("delayed completion published AgentReady for replacement prompt")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_UnnumberedCompleteCannotReleasePendingPrompt(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
generation, err := mgr.executionStore.BeginPrompt(execution.ID)
|
|
if err != nil {
|
|
t.Fatalf("begin prompt: %v", err)
|
|
}
|
|
mgr.executionStore.MarkPromptDispatched(execution.ID, generation)
|
|
execution.dispatchedPromptPending.Store(true)
|
|
|
|
if mgr.handleCompleteEvent(execution, &agentctl.AgentEvent{
|
|
Type: streams.EventTypeComplete,
|
|
SessionID: execution.SessionID,
|
|
Data: map[string]any{"stop_reason": "end_turn"},
|
|
}) {
|
|
t.Fatal("unnumbered completion was accepted while a numbered prompt was pending")
|
|
}
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
t.Fatalf("unnumbered completion released pending prompt: %+v", signal)
|
|
default:
|
|
}
|
|
if !execution.dispatchedPromptPending.Load() {
|
|
t.Fatal("unnumbered completion cleared the pending prompt gate")
|
|
}
|
|
for _, published := range eventBus.PublishedEvents {
|
|
if published.Subject == events.AgentReady {
|
|
t.Fatal("unnumbered completion published AgentReady for pending prompt")
|
|
}
|
|
}
|
|
|
|
if !mgr.handleCompleteEvent(execution, &agentctl.AgentEvent{
|
|
Type: streams.EventTypeComplete,
|
|
SessionID: execution.SessionID,
|
|
PromptGeneration: generation,
|
|
Data: map[string]any{"stop_reason": "end_turn"},
|
|
}) {
|
|
t.Fatal("numbered completion was rejected after unnumbered completion")
|
|
}
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
if signal.PromptGeneration != generation {
|
|
t.Fatalf("completion generation = %d, want %d", signal.PromptGeneration, generation)
|
|
}
|
|
default:
|
|
t.Fatal("numbered completion did not signal the pending prompt")
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_ForegroundIdlePreservesPromptGeneration(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-foreground-idle", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "foreground_idle",
|
|
SessionID: execution.SessionID,
|
|
PromptGeneration: 42,
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
if len(events) != 1 || events[0].Data == nil {
|
|
t.Fatalf("expected one foreground-idle stream event, got %#v", events)
|
|
}
|
|
if events[0].Data.PromptGeneration != 42 {
|
|
t.Fatalf("prompt generation = %d, want 42", events[0].Data.PromptGeneration)
|
|
}
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
t.Fatalf("unattested foreground idle released lifecycle waiter: %+v", signal)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_StalePromptHandoffCannotReleaseReplacement(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-stale-handoff", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
if _, err := mgr.executionStore.BeginPrompt(execution.ID); err != nil {
|
|
t.Fatalf("begin original prompt: %v", err)
|
|
}
|
|
if _, err := mgr.executionStore.BeginPrompt(execution.ID); err != nil {
|
|
t.Fatalf("begin replacement prompt: %v", err)
|
|
}
|
|
execution.messageMu.Lock()
|
|
execution.messageBuffer.WriteString("replacement output")
|
|
execution.messageMu.Unlock()
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: streams.EventTypeForegroundIdle,
|
|
PromptGeneration: 1,
|
|
Data: map[string]any{streams.AgentEventDataPromptHandoff: true},
|
|
})
|
|
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
t.Fatalf("stale handoff released replacement waiter: %+v", signal)
|
|
default:
|
|
}
|
|
execution.messageMu.Lock()
|
|
buffer := execution.messageBuffer.String()
|
|
execution.messageMu.Unlock()
|
|
if buffer != "replacement output" {
|
|
t.Fatalf("stale handoff mutated replacement buffer: %q", buffer)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentEvent_ErrorClaimCannotRaceReplacementPrompt(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
if err := mgr.executionStore.Add(execution); err != nil {
|
|
t.Fatalf("add execution: %v", err)
|
|
}
|
|
if _, err := mgr.executionStore.BeginPrompt(execution.ID); err != nil {
|
|
t.Fatalf("begin original prompt: %v", err)
|
|
}
|
|
|
|
errorReached := make(chan struct{})
|
|
releaseError := make(chan struct{})
|
|
var blockOnce sync.Once
|
|
zapLogger := zap.NewExample().WithOptions(zap.Hooks(func(entry zapcore.Entry) error {
|
|
if entry.Message != "agent error" && entry.Message != "agent turn complete" {
|
|
return nil
|
|
}
|
|
blockOnce.Do(func() {
|
|
close(errorReached)
|
|
<-releaseError
|
|
})
|
|
return nil
|
|
}))
|
|
mgr.logger, _ = logger.NewFromZap(zapLogger)
|
|
|
|
errorHandled := make(chan struct{})
|
|
go func() {
|
|
defer close(errorHandled)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "error",
|
|
Error: "original prompt failed",
|
|
SessionID: execution.SessionID,
|
|
PromptGeneration: 1,
|
|
})
|
|
}()
|
|
<-errorReached
|
|
|
|
errorOwnsLifecycle := !execution.promptLifecycleMu.TryLock()
|
|
if !errorOwnsLifecycle {
|
|
execution.promptLifecycleMu.Unlock()
|
|
}
|
|
|
|
replacementStarted := make(chan error, 1)
|
|
go func() {
|
|
_, err := mgr.executionStore.BeginPrompt(execution.ID)
|
|
replacementStarted <- err
|
|
}()
|
|
writeReplacementOutput := func() {
|
|
execution.messageMu.Lock()
|
|
execution.messageBuffer.WriteString("replacement output")
|
|
execution.currentMessageID = "replacement-message"
|
|
execution.messageMu.Unlock()
|
|
}
|
|
if !errorOwnsLifecycle {
|
|
if err := <-replacementStarted; err != nil {
|
|
t.Fatalf("begin replacement prompt: %v", err)
|
|
}
|
|
writeReplacementOutput()
|
|
}
|
|
|
|
close(releaseError)
|
|
if errorOwnsLifecycle {
|
|
if err := <-replacementStarted; err != nil {
|
|
t.Fatalf("begin replacement prompt: %v", err)
|
|
}
|
|
writeReplacementOutput()
|
|
}
|
|
<-errorHandled
|
|
|
|
if !errorOwnsLifecycle {
|
|
t.Error("error event released prompt lifecycle ownership before mutating execution state")
|
|
}
|
|
if execution.Status != v1.AgentStatusRunning {
|
|
t.Fatalf("replacement status = %s, want %s", execution.Status, v1.AgentStatusRunning)
|
|
}
|
|
execution.messageMu.Lock()
|
|
buffer := execution.messageBuffer.String()
|
|
messageID := execution.currentMessageID
|
|
execution.messageMu.Unlock()
|
|
if buffer != "replacement output" || messageID != "replacement-message" {
|
|
t.Fatalf("replacement stream state changed: buffer=%q message_id=%q", buffer, messageID)
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_ReasoningThenToolCall tests the scenario where thinking content
|
|
// accumulates without newlines (no streaming triggered), then a tool_call flushes the
|
|
// thinking buffer. The fix in flushMessageBuffer must clear currentThinkingID after
|
|
// publishStreamingThinking sets it as a side effect, so the next thinking segment starts fresh.
|
|
func TestHandleAgentEvent_ReasoningThenToolCall(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Reasoning without newlines — stays in buffer, no streaming emitted yet
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "reasoning",
|
|
ReasoningText: "thinking without newline",
|
|
})
|
|
|
|
// currentThinkingID should still be empty (no streaming happened yet)
|
|
execution.messageMu.Lock()
|
|
idBeforeToolCall := execution.currentThinkingID
|
|
execution.messageMu.Unlock()
|
|
if idBeforeToolCall != "" {
|
|
t.Errorf("currentThinkingID should be empty before flush, got %q", idBeforeToolCall)
|
|
}
|
|
|
|
// Tool call flushes the thinking buffer via flushMessageBuffer;
|
|
// publishStreamingThinking will set currentThinkingID as a side effect,
|
|
// then the fix must clear it.
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-1",
|
|
ToolName: "read_file",
|
|
})
|
|
|
|
// After the tool_call flush, currentThinkingID must be empty
|
|
execution.messageMu.Lock()
|
|
idAfterToolCall := execution.currentThinkingID
|
|
execution.messageMu.Unlock()
|
|
if idAfterToolCall != "" {
|
|
t.Errorf("currentThinkingID should be cleared after tool_call flush, got %q", idAfterToolCall)
|
|
}
|
|
|
|
// A thinking_streaming event should have been published for the buffered content
|
|
events := eventBus.getStreamEvents()
|
|
var thinkingEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "thinking_streaming" {
|
|
thinkingEvents = append(thinkingEvents, e)
|
|
}
|
|
}
|
|
if len(thinkingEvents) == 0 {
|
|
t.Error("expected thinking_streaming event to be published during tool_call flush")
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_ReasoningThenComplete tests that thinking content accumulated
|
|
// without newlines is flushed on complete, and currentThinkingID is cleared afterwards
|
|
// so a subsequent turn starts with a fresh thinking message.
|
|
func TestHandleAgentEvent_ReasoningThenComplete(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Reasoning without newlines — no streaming
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "reasoning",
|
|
ReasoningText: "brief thought",
|
|
})
|
|
|
|
// Complete flushes the buffer
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
})
|
|
|
|
// currentThinkingID must be empty after flush
|
|
execution.messageMu.Lock()
|
|
idAfterComplete := execution.currentThinkingID
|
|
execution.messageMu.Unlock()
|
|
if idAfterComplete != "" {
|
|
t.Errorf("currentThinkingID should be cleared after complete flush, got %q", idAfterComplete)
|
|
}
|
|
|
|
// A thinking_streaming event should have been published
|
|
events := eventBus.getStreamEvents()
|
|
var thinkingEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "thinking_streaming" {
|
|
thinkingEvents = append(thinkingEvents, e)
|
|
}
|
|
}
|
|
if len(thinkingEvents) == 0 {
|
|
t.Error("expected thinking_streaming event to be published during complete flush")
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_ReasoningWithNewlinesThenToolCall tests that reasoning content
|
|
// with newlines triggers streaming (sets currentThinkingID), a tool_call then flushes
|
|
// any remainder and clears currentThinkingID so the next thinking segment is a new message.
|
|
func TestHandleAgentEvent_ReasoningWithNewlinesThenToolCall(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
// Reasoning with newline — triggers streaming, sets currentThinkingID
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "reasoning",
|
|
ReasoningText: "step one\n",
|
|
})
|
|
|
|
execution.messageMu.Lock()
|
|
idAfterStreaming := execution.currentThinkingID
|
|
execution.messageMu.Unlock()
|
|
if idAfterStreaming == "" {
|
|
t.Error("currentThinkingID should be set after streamed reasoning chunk")
|
|
}
|
|
|
|
// Tool call flushes (empty remainder) and clears the ID
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_call",
|
|
ToolCallID: "tool-1",
|
|
ToolName: "read_file",
|
|
})
|
|
|
|
execution.messageMu.Lock()
|
|
idAfterToolCall := execution.currentThinkingID
|
|
execution.messageMu.Unlock()
|
|
if idAfterToolCall != "" {
|
|
t.Errorf("currentThinkingID should be cleared after tool_call, got %q", idAfterToolCall)
|
|
}
|
|
|
|
// Now reasoning again after tool_call — should start a NEW thinking message (IsAppend=false)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "reasoning",
|
|
ReasoningText: "step two\n",
|
|
})
|
|
|
|
events := eventBus.getStreamEvents()
|
|
var thinkingEvents []AgentStreamEventPayload
|
|
for _, e := range events {
|
|
if e.Data != nil && e.Data.Type == "thinking_streaming" {
|
|
thinkingEvents = append(thinkingEvents, e)
|
|
}
|
|
}
|
|
|
|
// Should have at least 2 thinking_streaming events: one before and one after tool_call
|
|
if len(thinkingEvents) < 2 {
|
|
t.Errorf("expected at least 2 thinking_streaming events, got %d", len(thinkingEvents))
|
|
}
|
|
|
|
// The second thinking segment must start a new message (IsAppend=false)
|
|
lastEvent := thinkingEvents[len(thinkingEvents)-1]
|
|
if lastEvent.Data.IsAppend {
|
|
t.Errorf("reasoning chunk after tool_call should start a new thinking message, but got IsAppend=true")
|
|
}
|
|
}
|
|
|
|
// TestExtractErrorMessage verifies the priority chain: Error > Text > default.
|
|
func TestExtractErrorMessage(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
event *agentctl.AgentEvent
|
|
want string
|
|
}{
|
|
{
|
|
name: "Error field takes priority",
|
|
event: &agentctl.AgentEvent{Error: "explicit error", Text: "text fallback"},
|
|
want: "explicit error",
|
|
},
|
|
{
|
|
name: "Text field used when Error is empty",
|
|
event: &agentctl.AgentEvent{Error: "", Text: "text fallback"},
|
|
want: "text fallback",
|
|
},
|
|
{
|
|
name: "default when both empty",
|
|
event: &agentctl.AgentEvent{Error: "", Text: ""},
|
|
want: "agent error completion",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := extractErrorMessage(tt.event)
|
|
if got != tt.want {
|
|
t.Errorf("extractErrorMessage() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_ErrorCompleteWithTextFallback verifies that when an error
|
|
// completion has no Error field but has Text, the Text is used as the error message.
|
|
func TestHandleAgentEvent_ErrorCompleteWithTextFallback(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
Text: "Operation timed out",
|
|
Data: map[string]any{"is_error": true},
|
|
})
|
|
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
if !signal.IsError {
|
|
t.Error("expected error signal")
|
|
}
|
|
if signal.Error != "Operation timed out" {
|
|
t.Errorf("expected error 'Operation timed out', got %q", signal.Error)
|
|
}
|
|
default:
|
|
t.Error("expected signal on promptDoneCh, got none")
|
|
}
|
|
}
|
|
|
|
// TestHandleAgentEvent_ErrorCompleteWithDefaultMessage verifies that when both
|
|
// Error and Text fields are empty, the default message is used.
|
|
func TestHandleAgentEvent_ErrorCompleteWithDefaultMessage(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "complete",
|
|
Data: map[string]any{"is_error": true},
|
|
})
|
|
|
|
select {
|
|
case signal := <-execution.promptDoneCh:
|
|
if !signal.IsError {
|
|
t.Error("expected error signal")
|
|
}
|
|
if signal.Error != "agent error completion" {
|
|
t.Errorf("expected default error message, got %q", signal.Error)
|
|
}
|
|
default:
|
|
t.Error("expected signal on promptDoneCh, got none")
|
|
}
|
|
}
|
|
|
|
// TestHandleCompleteEventMarkState_ErrorDoesNotRemoveExecution verifies that on error
|
|
// completion, the execution is NOT removed from the store. The orchestrator's cleanup
|
|
// (StopExecution → StopAgentWithReason) handles full teardown including port release;
|
|
// premature removal would prevent that cleanup from finding the execution.
|
|
func TestHandleCompleteEventMarkState_ErrorDoesNotRemoveExecution(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
errorEvent := &agentctl.AgentEvent{
|
|
Type: "complete",
|
|
Error: "agent crashed",
|
|
Data: map[string]interface{}{"is_error": true},
|
|
}
|
|
|
|
mgr.handleCompleteEventMarkState(execution, errorEvent, true, nil)
|
|
|
|
// Execution must still be in the store so the orchestrator can clean it up
|
|
if _, found := mgr.executionStore.Get("exec-1"); !found {
|
|
t.Error("execution was removed from store on error completion; " +
|
|
"it should remain so the orchestrator can call StopExecution to release resources")
|
|
}
|
|
}
|
|
|
|
// TestHandleCompleteEventMarkState_SuccessKeepsExecution verifies that on normal
|
|
// completion, the execution remains in the store (marked ready, not removed).
|
|
func TestHandleCompleteEventMarkState_SuccessKeepsExecution(t *testing.T) {
|
|
mgr, _ := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
mgr.executionStore.Add(execution)
|
|
|
|
successEvent := &agentctl.AgentEvent{
|
|
Type: "complete",
|
|
}
|
|
|
|
mgr.handleCompleteEventMarkState(execution, successEvent, false, nil)
|
|
|
|
got, found := mgr.executionStore.Get("exec-1")
|
|
if !found {
|
|
t.Error("execution was removed from store on successful completion; it should remain")
|
|
}
|
|
if found && got.Status != v1.AgentStatusReady {
|
|
t.Errorf("expected status %q after success, got %q", v1.AgentStatusReady, got.Status)
|
|
}
|
|
}
|