89bf7657a2
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
308 lines
13 KiB
Go
308 lines
13 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"testing"
|
|
|
|
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
|
"github.com/kandev/kandev/internal/events"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// TestWakeup_SecondTurnAgentReadyIsSuppressed reproduces the bug deterministically
|
|
// without needing the bridge or auth.
|
|
//
|
|
// Scenario: an execution receives events for two consecutive turns. The first
|
|
// turn is user-initiated (status flips Running → Ready on complete, agent.ready
|
|
// fires). The second turn is wakeup-initiated — kandev's wakeup scheduler calls
|
|
// adapter.Prompt directly, which does NOT flip the execution back to Running
|
|
// (that's done by SessionManager.SendPrompt, which fireWakeup bypasses).
|
|
//
|
|
// When the second `complete` event arrives, the manager calls MarkReady. But
|
|
// MarkReady at manager_interaction.go:896 has:
|
|
//
|
|
// if execution.Status == v1.AgentStatusReady {
|
|
// return nil
|
|
// }
|
|
//
|
|
// Since the execution is still Ready (never went back to Running), this early-
|
|
// returns, no agent.ready is published, and the orchestrator never sees the
|
|
// wakeup turn end → completeTurnForSession never fires → workflow on_turn_complete
|
|
// never evaluates.
|
|
//
|
|
// The wakeup-turn's message_streaming events DO still reach the bus (those
|
|
// don't gate on execution status), so the persisted chat history is correct.
|
|
// But workflow state and queued-message dispatch are broken.
|
|
func TestWakeup_SecondTurnAgentReadyIsSuppressed(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
_ = mgr.executionStore.Add(execution)
|
|
|
|
// === Turn 1: user-initiated prompt ===
|
|
//
|
|
// In production: SessionManager.SendPrompt() runs and calls
|
|
// sm.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning)
|
|
// We mirror that here.
|
|
mgr.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning)
|
|
|
|
// Assistant text + tool call + complete (the agent calls ScheduleWakeup).
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk", Text: "Scheduled.\n",
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
// === Turn 2: wakeup-initiated prompt ===
|
|
//
|
|
// In production: the wakeupScheduler timer fires → adapter.fireWakeup() →
|
|
// adapter.Prompt(ctx, prompt, nil). The lifecycle layer is NOT involved
|
|
// in initiating this prompt, so executionStore.UpdateStatus(Running) is
|
|
// NEVER called. We mirror that by leaving the execution status as-is.
|
|
|
|
// Wakeup-turn text + complete arrive via the adapter's update stream.
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk", Text: "WAKEUP_FIRED\n",
|
|
})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
// === Inspect what the manager published ===
|
|
readyCount := 0
|
|
streamingTexts := []string{}
|
|
streamCompleteCount := 0
|
|
for _, te := range eventBus.PublishedEvents {
|
|
if te.Event == nil {
|
|
continue
|
|
}
|
|
if te.Event.Type == events.AgentReady {
|
|
readyCount++
|
|
}
|
|
if payload, ok := te.Event.Data.(AgentStreamEventPayload); ok && payload.Data != nil {
|
|
switch payload.Data.Type {
|
|
case "message_streaming":
|
|
streamingTexts = append(streamingTexts, payload.Data.Text)
|
|
case "complete":
|
|
streamCompleteCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Logf("agent.ready events published: %d (expect 2)", readyCount)
|
|
t.Logf("message_streaming events: %v", streamingTexts)
|
|
t.Logf("AgentStreamEventPayload type=complete events: %d (expect 2)", streamCompleteCount)
|
|
t.Logf("execution.Status at end: %s", execution.Status)
|
|
|
|
// The agent-stream-level events (message_streaming, complete) are NOT
|
|
// gated on execution status — they should reach the bus for BOTH turns.
|
|
if streamCompleteCount != 2 {
|
|
t.Errorf("expected 2 stream-level complete events, got %d", streamCompleteCount)
|
|
}
|
|
if len(streamingTexts) != 2 {
|
|
t.Errorf("expected 2 message_streaming events (one per turn), got %d: %v",
|
|
len(streamingTexts), streamingTexts)
|
|
}
|
|
|
|
// The bug: agent.ready is suppressed on the second turn because the
|
|
// execution status is already Ready when MarkReady runs.
|
|
if readyCount != 2 {
|
|
t.Errorf(
|
|
"expected exactly 2 agent.ready events, got %d. "+
|
|
"MarkReady at manager_interaction.go:896 suppresses the wakeup turn's "+
|
|
"AgentReady because fireWakeup bypasses SessionManager.SendPrompt and "+
|
|
"never flips the execution back to Running. The orchestrator therefore "+
|
|
"never receives agent.ready for the wakeup turn → "+
|
|
"completeTurnForSession is not called → workflow on_turn_complete is "+
|
|
"not evaluated → queued messages are not dispatched.",
|
|
readyCount,
|
|
)
|
|
}
|
|
|
|
// Ordering check: for each AgentReady, there must be a preceding
|
|
// AgentRunning. The orchestrator's handleAgentReady early-returns when
|
|
// session.State is not Running/Starting, so an AgentReady on the bus
|
|
// is only useful if the matching AgentRunning has flipped session
|
|
// state first. Verify by walking the published events in order and
|
|
// requiring `running_count >= ready_count` at every AgentReady index.
|
|
running, ready := 0, 0
|
|
for _, te := range eventBus.PublishedEvents {
|
|
if te.Event == nil {
|
|
continue
|
|
}
|
|
switch te.Event.Type {
|
|
case events.AgentRunning:
|
|
running++
|
|
case events.AgentReady:
|
|
ready++
|
|
if running < ready {
|
|
t.Errorf("AgentReady #%d published before matching AgentRunning (running=%d at that point) — orchestrator's handleAgentReady will silently drop this", ready, running)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWakeup_PostBootMetadataEventsDoNotFlipToRunning verifies that boot-time
|
|
// metadata events (available_commands_update, agent_capabilities, context_window
|
|
// etc.) arriving AFTER MarkBootReady has put the execution into Ready do NOT
|
|
// re-arm the execution as Running. claude-agent-acp emits
|
|
// available_commands_update asynchronously ~50ms after session/new — well
|
|
// after dispatchInitialPrompt fires MarkBootReady for a no-prompt task —
|
|
// and an over-eager Ready → Running flip on that event would put the session
|
|
// into Running state in the orchestrator and break the chat-input UI for
|
|
// freshly-created tasks awaiting their first user message.
|
|
func TestWakeup_PostBootMetadataEventsDoNotFlipToRunning(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
_ = mgr.executionStore.Add(execution)
|
|
|
|
// Prime firstActivityOnce so it's already consumed before the post-boot
|
|
// metadata events arrive. In production, the adapter always emits
|
|
// `agent_capabilities` during Initialize (before MarkBootReady), which
|
|
// consumes firstActivityOnce while Status is still Running. The
|
|
// `Sync.Once.Do` call here mirrors that.
|
|
execution.firstActivityOnce.Do(func() {})
|
|
|
|
// Mirror the no-prompt boot path: dispatchInitialPrompt → MarkBootReady →
|
|
// status flips to Ready.
|
|
mgr.executionStore.UpdateStatus(execution.ID, v1.AgentStatusReady)
|
|
|
|
// Boot metadata events arrive after MarkBootReady. None of these
|
|
// indicate a new turn — they're protocol metadata.
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "available_commands"})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "agent_capabilities"})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "session_mode"})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "session_models"})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "context_window"})
|
|
|
|
runningCount := 0
|
|
for _, te := range eventBus.PublishedEvents {
|
|
if te.Event != nil && te.Event.Type == events.AgentRunning {
|
|
runningCount++
|
|
}
|
|
}
|
|
// With firstActivityOnce already consumed, the only way to publish
|
|
// AgentRunning would be the post-boot whitelist flip. None of these
|
|
// metadata events are on the whitelist, so the count must be exactly 0.
|
|
if runningCount != 0 {
|
|
t.Errorf("expected 0 agent.running events from post-boot metadata, got %d — metadata-driven re-arming is broken", runningCount)
|
|
}
|
|
if execution.Status != v1.AgentStatusReady {
|
|
t.Errorf("execution.Status = %q, want %q — boot metadata events should not change status", execution.Status, v1.AgentStatusReady)
|
|
}
|
|
}
|
|
|
|
func TestWakeup_LateTerminalToolUpdateDoesNotFlipToRunning(t *testing.T) {
|
|
for _, status := range []string{"complete", "completed", "success", "error", "failed", "cancelled"} {
|
|
t.Run(status, func(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
_ = mgr.executionStore.Add(execution)
|
|
|
|
execution.firstActivityOnce.Do(func() {})
|
|
mgr.executionStore.UpdateStatus(execution.ID, v1.AgentStatusReady)
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "tool_update",
|
|
ToolCallID: "tool-1",
|
|
ToolStatus: status,
|
|
})
|
|
|
|
for _, te := range eventBus.PublishedEvents {
|
|
if te.Event != nil && te.Event.Type == events.AgentRunning {
|
|
t.Fatal("standalone terminal tool update must not publish agent.running")
|
|
}
|
|
}
|
|
if execution.Status != v1.AgentStatusReady {
|
|
t.Fatalf("execution.Status = %q, want %q", execution.Status, v1.AgentStatusReady)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWakeup_MarkedProviderDiagnosticChunkDoesNotFlipToRunning pins the
|
|
// AC-PLATFORM-PROVIDER-ERROR-RECOVERY-001.21 "shall not advance turn
|
|
// progress" clause at recordActivity's wakeup Ready->Running flip. A marked
|
|
// message_chunk arriving while Ready has the exact same shape as a genuine
|
|
// wakeup turn's first content event (turnContentEventTypes membership, status
|
|
// already Ready) but must not re-arm the execution as Running.
|
|
func TestWakeup_MarkedProviderDiagnosticChunkDoesNotFlipToRunning(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
_ = mgr.executionStore.Add(execution)
|
|
|
|
// Turn 1: normal user turn puts the execution back to Ready.
|
|
mgr.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "message_chunk", Text: "ok\n"})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
if execution.Status != v1.AgentStatusReady {
|
|
t.Fatalf("setup: execution.Status = %q, want %q", execution.Status, v1.AgentStatusReady)
|
|
}
|
|
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
|
|
Type: "message_chunk",
|
|
Text: "API Error: 500 Internal Server Error",
|
|
ProviderDiagnosticCandidate: true,
|
|
})
|
|
|
|
runningCount := 0
|
|
for _, te := range eventBus.PublishedEvents {
|
|
if te.Event != nil && te.Event.Type == events.AgentRunning {
|
|
runningCount++
|
|
}
|
|
}
|
|
if runningCount != 1 {
|
|
t.Errorf("expected exactly 1 agent.running event (turn 1's boot-time flip only), got %d — a marked provider-diagnostic chunk must not advance turn progress", runningCount)
|
|
}
|
|
if execution.Status != v1.AgentStatusReady {
|
|
t.Errorf("execution.Status = %q, want %q — marked diagnostic chunk must not flip Ready back to Running", execution.Status, v1.AgentStatusReady)
|
|
}
|
|
}
|
|
|
|
// TestWakeup_EmptyTurnStillPublishesAgentReady covers the narrow edge case
|
|
// where a wakeup turn produces *only* a `complete` event (no preceding
|
|
// message_chunk/tool_call/etc) — e.g. when the model returns an empty
|
|
// response on wake-up. recordActivity skips the flip on `complete` events
|
|
// (they're outside the turn-content whitelist), so handleCompleteEventMarkState
|
|
// must publish AgentReady directly when it detects Status is already Ready.
|
|
func TestWakeup_EmptyTurnStillPublishesAgentReady(t *testing.T) {
|
|
mgr, eventBus := createTestManagerWithTracking()
|
|
execution := createTestExecution("exec-1", "task-1", "session-1")
|
|
_ = mgr.executionStore.Add(execution)
|
|
|
|
// Turn 1: normal user turn.
|
|
mgr.executionStore.UpdateStatus(execution.ID, v1.AgentStatusRunning)
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "message_chunk", Text: "ok\n"})
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
// Turn 2: wakeup turn with ONLY a complete event — the agent woke up
|
|
// and immediately decided it had nothing to say.
|
|
mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"})
|
|
|
|
readyCount := 0
|
|
for _, te := range eventBus.PublishedEvents {
|
|
if te.Event != nil && te.Event.Type == events.AgentReady {
|
|
readyCount++
|
|
}
|
|
}
|
|
if readyCount != 2 {
|
|
t.Errorf("expected 2 agent.ready events (one per turn, including the empty wakeup turn), got %d", readyCount)
|
|
}
|
|
|
|
// The empty-wakeup fallback in handleCompleteEventMarkState must publish
|
|
// AgentRunning before AgentReady so the orchestrator's session-state
|
|
// guard (handleAgentReady early-returns on non-Running/Starting state)
|
|
// lets AgentReady through.
|
|
running, ready := 0, 0
|
|
for _, te := range eventBus.PublishedEvents {
|
|
if te.Event == nil {
|
|
continue
|
|
}
|
|
switch te.Event.Type {
|
|
case events.AgentRunning:
|
|
running++
|
|
case events.AgentReady:
|
|
ready++
|
|
if running < ready {
|
|
t.Errorf("AgentReady #%d for empty wakeup published before matching AgentRunning — orchestrator will drop it", ready)
|
|
}
|
|
}
|
|
}
|
|
}
|