fix(acp): flush ScheduleWakeup output via synthetic prompt (#706)

This commit is contained in:
João Salavisa
2026-04-26 21:07:22 +01:00
committed by GitHub
parent 5644b0ad18
commit 8948ebdb25
3 changed files with 801 additions and 1 deletions
@@ -11,6 +11,7 @@ import (
"log/slog"
"strings"
"sync"
"time"
"github.com/coder/acp-go-sdk"
acpclient "github.com/kandev/kandev/internal/agentctl/server/acp"
@@ -40,6 +41,12 @@ const (
contentTypeResource = "resource"
)
// wakeupPromptTimeout bounds how long a synthetic wakeup prompt can run.
// Wakeup turns can perform real work (the model often runs a few tool calls
// before stopping) so we mirror what a normal user-initiated prompt would
// allow rather than a tight RPC deadline.
const wakeupPromptTimeout = 30 * time.Minute
// AgentInfo contains information about the connected agent.
type AgentInfo struct {
Name string `json:"name"`
@@ -104,6 +111,16 @@ type Adapter struct {
// and rebuilt during session/load replay.
activeMonitors map[string]map[string]string
// ScheduleWakeup tracking. The Claude Agent SDK's ScheduleWakeup tool fires
// its timer inside the SDK's async-iterator, but the upstream
// @agentclientprotocol/claude-agent-acp bridge only drains that iterator
// inside its prompt() handler — so a wakeup that fires while no prompt is
// in flight produces no output. wakeup re-injects the wakeup as a synthetic
// session/prompt at fire time. pendingWakeups tracks per-tool-call info
// (prompt + scheduledFor) since these arrive in separate notifications.
wakeup *wakeupScheduler
pendingWakeups map[string]*pendingWakeup
// OTel tracing: active prompt span context.
// Notification spans become children of the prompt span for visual grouping.
promptTraceCtx context.Context
@@ -124,6 +141,13 @@ type Adapter struct {
// Synchronization
mu sync.RWMutex
closed bool
// lifetimeCtx is cancelled by Close. Background work that may outlive
// the call site (e.g. the synthetic wakeup prompt goroutine) derives its
// context from this one so it aborts when the adapter shuts down rather
// than continuing to drive a dead subprocess.
lifetimeCtx context.Context
lifetimeCancel context.CancelFunc
}
// NewAdapter creates a new ACP protocol adapter.
@@ -131,7 +155,8 @@ type Adapter struct {
// cfg.AgentID is required for debug file naming.
func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter {
l := log.WithFields(zap.String("adapter", "acp"), zap.String("agent_id", cfg.AgentID))
return &Adapter{
ctx, cancel := context.WithCancel(context.Background())
a := &Adapter{
cfg: cfg,
logger: l,
agentID: cfg.AgentID,
@@ -139,8 +164,13 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter {
updatesCh: make(chan AgentEvent, 100),
activeToolCalls: make(map[string]*streams.NormalizedPayload),
activeMonitors: make(map[string]map[string]string),
pendingWakeups: make(map[string]*pendingWakeup),
attachMgr: shared.NewAttachmentManager(cfg.WorkDir, l.Zap()),
lifetimeCtx: ctx,
lifetimeCancel: cancel,
}
a.wakeup = newWakeupScheduler(l, a.fireWakeup)
return a
}
// PrepareEnvironment is a no-op for ACP.
@@ -252,6 +282,15 @@ func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer)
return "", fmt.Errorf("adapter not initialized")
}
// A fresh session invalidates any pending wakeup keyed to the prior
// session. Reset pendingWakeups and cancel the scheduler under one
// a.mu critical section so a concurrent handleWakeupEvent can't slip
// a stale entry between the two operations.
a.mu.Lock()
a.pendingWakeups = make(map[string]*pendingWakeup)
a.wakeup.cancel()
a.mu.Unlock()
ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "session.new")
defer span.End()
@@ -423,6 +462,15 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers
return fmt.Errorf("agent does not support session loading (LoadSession capability is false)")
}
// Loading a different session invalidates any pending wakeup keyed to the
// prior session — same reset block as NewSession to avoid leaving an armed
// timer for a session id that's about to change and accumulating stale
// pendingWakeups entries across reloads.
a.mu.Lock()
a.pendingWakeups = make(map[string]*pendingWakeup)
a.wakeup.cancel()
a.mu.Unlock()
ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "session.load")
defer span.End()
@@ -653,6 +701,96 @@ func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.M
return nil
}
// fireWakeup is invoked by wakeupScheduler when a ScheduleWakeup timer
// elapses. It issues a synthetic session/prompt so the upstream
// @agentclientprotocol/claude-agent-acp bridge drains the SDK's queued wakeup
// turn and emits visible ACP frames. The session must still match (the user
// hasn't started a fresh session) and the adapter must not be closed.
//
// Concurrent-prompt safety: if a user prompt is already in flight when this
// runs, both end up calling conn.Prompt() on the same ClientSideConnection.
// That's safe at the wire level — the ACP SDK's Connection.sendMessage
// holds a write mutex, so request frames never interleave on stdin, and
// JSON-RPC pairs each response back to its originating request via id —
// but it does mean two prompts can be in flight against the bridge at
// once. The bridge serialises them in the order it receives them, which
// is exactly what we want for a wakeup that races a user message.
func (a *Adapter) fireWakeup(sessionID, prompt string) {
a.mu.RLock()
closed := a.closed
currentSession := a.sessionID
a.mu.RUnlock()
if closed {
a.logger.Debug("skipping wakeup fire: adapter closed",
zap.String("session_id", sessionID))
return
}
if currentSession != sessionID {
a.logger.Info("skipping wakeup fire: session changed",
zap.String("scheduled_for", sessionID),
zap.String("current", currentSession))
return
}
a.logger.Info("injecting synthetic wakeup prompt",
zap.String("session_id", sessionID),
zap.Int("prompt_len", len(prompt)))
go func() {
// Derive from lifetimeCtx so a concurrent Close aborts the in-flight
// prompt instead of letting it run against a dead subprocess.
ctx, cancel := context.WithTimeout(a.lifetimeCtx, wakeupPromptTimeout)
defer cancel()
if err := a.Prompt(ctx, prompt, nil); err != nil {
a.logger.Error("synthetic wakeup prompt failed",
zap.String("session_id", sessionID),
zap.Error(err))
}
}()
}
// handleWakeupEvent inspects a tool-call meta + rawInput pair, accumulates
// pending state per toolCallID, and schedules a wakeup once both the prompt
// and scheduledFor timestamp are known. terminal=true means the tool call has
// reached a terminal state, so any pending entry should be cleaned up.
func (a *Adapter) handleWakeupEvent(sessionID, toolCallID string, meta any, rawInput any, terminal bool) {
if toolCallID == "" {
return
}
scheduledForMs, isWakeup := extractScheduleWakeup(meta)
a.mu.Lock()
pw, tracked := a.pendingWakeups[toolCallID]
if !tracked {
if !isWakeup {
a.mu.Unlock()
return
}
pw = &pendingWakeup{}
a.pendingWakeups[toolCallID] = pw
}
if scheduledForMs > 0 {
pw.scheduledForMs = scheduledForMs
}
if prompt, ok := extractWakeupPrompt(rawInput); ok {
pw.prompt = prompt
}
prompt := pw.prompt
stamp := pw.scheduledForMs
if (prompt != "" && stamp > 0) || terminal {
delete(a.pendingWakeups, toolCallID)
}
a.mu.Unlock()
if prompt != "" && stamp > 0 {
a.wakeup.schedule(sessionID, prompt, stamp)
}
}
// SetPendingContext sets the context to be injected into the next prompt.
// This is used by the fork_session pattern for ACP agents that don't support session/load.
// The context will be prepended to the first prompt sent to this session.
@@ -785,6 +923,15 @@ func (a *Adapter) Close() error {
a.logger.Info("closing ACP adapter")
// Stop any pending ScheduleWakeup timer so it doesn't fire after close,
// and cancel the lifetime context so any in-flight wakeup prompt aborts.
if a.wakeup != nil {
a.wakeup.cancel()
}
if a.lifetimeCancel != nil {
a.lifetimeCancel()
}
// Clean up any saved attachments
a.attachMgr.Cleanup()
@@ -1432,6 +1579,11 @@ func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateT
a.activeToolCalls[toolCallID] = normalizedPayload
a.mu.Unlock()
// ScheduleWakeup tracking: meta carries `_meta.claudeCode.toolName`
// on the initial tool_call; rawInput is usually empty here but record
// the prompt eagerly when it does arrive in the same notification.
a.handleWakeupEvent(sessionID, toolCallID, tc.Meta, tc.RawInput, false)
// Detect tool type for logging
toolType := DetectToolOperationType(toolKind, args)
_ = toolType // Used for normalization
@@ -1557,6 +1709,11 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session
}
a.mu.Unlock()
// ScheduleWakeup tracking: tool_call_update is where rawInput.prompt and
// `_meta.claudeCode.toolResponse.scheduledFor` typically arrive. Once both
// are known, schedule the synthetic prompt; on terminal status, clean up.
a.handleWakeupEvent(sessionID, toolCallID, tcu.Meta, tcu.RawInput, isTerminal)
// When a switch_mode tool carries a plan (e.g. ExitPlanMode), emit it
// as an agent_plan event so the orchestrator creates a visible plan message.
if tcu.RawInput != nil {
@@ -0,0 +1,197 @@
package acp
import (
"sync"
"time"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// wakeupScheduler holds at most one pending ScheduleWakeup-driven prompt for
// the adapter. The Claude Agent SDK exposes `ScheduleWakeup` as a tool inside
// its harness; when its timer fires, the SDK queues a new turn on its
// async-iterator (`session.query`). The upstream
// @agentclientprotocol/claude-agent-acp bridge only iterates that channel
// inside its `prompt()` handler, so a wakeup that fires while no
// `session/prompt` is in flight produces no ACP output at all — the buffered
// turn only drains on the next user message.
//
// This scheduler closes the gap: when we observe a `ScheduleWakeup` tool
// completion, we record `scheduledFor` and the prompt text, and at fire time
// issue a synthetic `session/prompt` to the bridge. That triggers the bridge
// to drain the buffered output and produce visible ACP frames for the wakeup
// turn — matching what standalone Claude Code does, where the wakeup prompt
// is injected as a synthesized user turn.
type wakeupScheduler struct {
logger *logger.Logger
fire func(sessionID, prompt string)
mu sync.Mutex
timer *time.Timer
sessionID string
prompt string
// gen is incremented on every schedule/cancel. fireOnce captures the gen
// at scheduling time and refuses to fire if it doesn't match the current
// gen — that closes the race where time.AfterFunc has already launched a
// goroutine for the old timer (timer.Stop() returns false) and the new
// schedule() then writes new state before the stale fireOnce acquires the
// lock. Without gen, the stale fireOnce would consume the new state and
// fire the new wakeup immediately while clobbering w.timer.
gen uint64
}
// pendingWakeup accumulates partial state for an in-flight ScheduleWakeup tool
// call. The bridge emits the wakeup metadata across multiple notifications:
// the initial tool_call carries `_meta.claudeCode.toolName="ScheduleWakeup"`
// (no rawInput yet); subsequent tool_call_updates fill in `rawInput.prompt`
// and `_meta.claudeCode.toolResponse.scheduledFor`. We only have enough to
// schedule once both the prompt and scheduledFor timestamp are present.
type pendingWakeup struct {
prompt string
scheduledForMs int64
}
func newWakeupScheduler(log *logger.Logger, fire func(sessionID, prompt string)) *wakeupScheduler {
return &wakeupScheduler{
logger: log.WithFields(zap.String("component", "wakeup-scheduler")),
fire: fire,
}
}
// schedule registers a wakeup for the given session, replacing any prior
// pending wakeup. unixMs is the Unix-epoch millisecond timestamp at which the
// wakeup should fire (matches the `_meta.claudeCode.toolResponse.scheduledFor`
// field reported by the bridge). If the timestamp is in the past or zero, the
// wakeup is dropped without firing — that matches the ACP semantics of "the
// turn already happened" and avoids unbounded immediate-fire loops on stale
// timestamps.
func (w *wakeupScheduler) schedule(sessionID, prompt string, unixMs int64) {
if sessionID == "" || prompt == "" || unixMs <= 0 {
w.logger.Warn("ignoring wakeup with missing fields",
zap.String("session_id", sessionID),
zap.Int("prompt_len", len(prompt)),
zap.Int64("unix_ms", unixMs))
return
}
delay := time.Until(time.UnixMilli(unixMs))
if delay <= 0 {
// Warn rather than Debug: a stale wakeup typically signals system
// suspension, container pause, or significant clock skew — those are
// production-relevant and shouldn't be hidden at default log levels.
w.logger.Warn("dropping stale wakeup",
zap.String("session_id", sessionID),
zap.Duration("past_by", -delay))
return
}
w.mu.Lock()
if w.timer != nil {
w.timer.Stop()
}
w.gen++
myGen := w.gen
w.sessionID = sessionID
w.prompt = prompt
w.timer = time.AfterFunc(delay, func() { w.fireOnce(myGen) })
w.mu.Unlock()
w.logger.Info("scheduled wakeup",
zap.String("session_id", sessionID),
zap.Time("fires_at", time.UnixMilli(unixMs)),
zap.Duration("delay", delay))
}
// fireOnce is invoked by the timer and dispatches to the configured callback.
// myGen identifies the schedule call that armed this timer; if a later
// schedule or cancel has bumped w.gen, this fire is stale and is dropped.
// It clears the pending state before calling the callback so a re-entry from
// inside the callback (e.g. the synthetic prompt schedules another wakeup)
// can overwrite cleanly. The gen check guarantees sessionID is non-empty
// here — schedule() rejects empty sessionID before bumping gen, and any
// cancel would have bumped gen too — so we don't re-check it.
func (w *wakeupScheduler) fireOnce(myGen uint64) {
w.mu.Lock()
if myGen != w.gen {
w.mu.Unlock()
return
}
sessionID := w.sessionID
prompt := w.prompt
w.timer = nil
w.sessionID = ""
w.prompt = ""
w.gen++
w.mu.Unlock()
w.logger.Info("firing wakeup",
zap.String("session_id", sessionID))
w.fire(sessionID, prompt)
}
// cancel stops any pending wakeup. Safe to call multiple times. Bumping gen
// invalidates any in-flight fireOnce that has already been launched but is
// blocked on the lock — without that, a stale fireOnce after Stop()=false
// would still observe the cleared state and no-op cleanly, but cancel races
// with concurrent schedule could otherwise let a stale fire consume newly
// scheduled state.
func (w *wakeupScheduler) cancel() {
w.mu.Lock()
defer w.mu.Unlock()
if w.timer != nil {
w.timer.Stop()
w.timer = nil
}
w.sessionID = ""
w.prompt = ""
w.gen++
}
// extractScheduleWakeup inspects an ACP tool-call meta map and returns
// (scheduledForUnixMs, isScheduleWakeup). It expects the bridge's
// `_meta.claudeCode.toolName` and `_meta.claudeCode.toolResponse.scheduledFor`
// shape that @agentclientprotocol/claude-agent-acp emits; missing/malformed
// entries are reported as not-a-wakeup rather than as errors so unrelated
// tools pass through unaffected.
func extractScheduleWakeup(meta any) (scheduledForMs int64, isWakeup bool) {
m, ok := meta.(map[string]any)
if !ok {
return 0, false
}
cc, ok := m["claudeCode"].(map[string]any)
if !ok {
return 0, false
}
if name, _ := cc["toolName"].(string); name != "ScheduleWakeup" {
return 0, false
}
resp, ok := cc["toolResponse"].(map[string]any)
if !ok {
return 0, true // ScheduleWakeup tool, but response not yet present
}
switch v := resp["scheduledFor"].(type) {
case float64:
return int64(v), true
case int64:
return v, true
case int:
return int64(v), true
}
return 0, true
}
// extractWakeupPrompt pulls the prompt string out of a ScheduleWakeup
// tool-call rawInput. Returns ("", false) when the field is absent or the
// wrong type.
func extractWakeupPrompt(rawInput any) (string, bool) {
m, ok := rawInput.(map[string]any)
if !ok {
return "", false
}
s, ok := m["prompt"].(string)
if !ok || s == "" {
return "", false
}
return s, true
}
@@ -0,0 +1,446 @@
package acp
import (
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/kandev/kandev/internal/common/logger"
)
func newTestWakeupLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
OutputPath: "stderr",
})
if err != nil {
t.Fatalf("logger init failed: %v", err)
}
return log
}
func TestExtractScheduleWakeup_NotAWakeup(t *testing.T) {
cases := []struct {
name string
meta any
}{
{"nil", nil},
{"non-map", "string"},
{"empty map", map[string]any{}},
{"missing claudeCode", map[string]any{"other": "x"}},
{"claudeCode not a map", map[string]any{"claudeCode": "x"}},
{"different tool", map[string]any{
"claudeCode": map[string]any{"toolName": "Bash"},
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ms, ok := extractScheduleWakeup(c.meta)
if ok {
t.Errorf("expected isWakeup=false for %s, got true (ms=%d)", c.name, ms)
}
})
}
}
func TestExtractScheduleWakeup_NoResponseYet(t *testing.T) {
meta := map[string]any{
"claudeCode": map[string]any{
"toolName": "ScheduleWakeup",
},
}
ms, ok := extractScheduleWakeup(meta)
if !ok {
t.Fatal("expected isWakeup=true")
}
if ms != 0 {
t.Errorf("expected ms=0 when toolResponse missing, got %d", ms)
}
}
func TestExtractScheduleWakeup_WithResponse(t *testing.T) {
cases := []struct {
name string
val any
want int64
}{
{"float64", float64(1700000000000), 1700000000000},
{"int", int(1700000000000), 1700000000000},
{"int64", int64(1700000000000), 1700000000000},
{"unsupported type", "1700000000000", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
meta := map[string]any{
"claudeCode": map[string]any{
"toolName": "ScheduleWakeup",
"toolResponse": map[string]any{
"scheduledFor": c.val,
},
},
}
ms, ok := extractScheduleWakeup(meta)
if !ok {
t.Fatal("expected isWakeup=true")
}
if ms != c.want {
t.Errorf("ms=%d, want %d", ms, c.want)
}
})
}
}
func TestExtractWakeupPrompt(t *testing.T) {
cases := []struct {
name string
input any
wantOK bool
wantText string
}{
{"nil input", nil, false, ""},
{"non-map", "x", false, ""},
{"empty map", map[string]any{}, false, ""},
{"empty prompt", map[string]any{"prompt": ""}, false, ""},
{"non-string prompt", map[string]any{"prompt": 42}, false, ""},
{"valid prompt", map[string]any{"prompt": "hello"}, true, "hello"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := extractWakeupPrompt(c.input)
if ok != c.wantOK || got != c.wantText {
t.Errorf("got (%q, %v), want (%q, %v)", got, ok, c.wantText, c.wantOK)
}
})
}
}
type wakeupCapture struct {
mu sync.Mutex
calls int32
lastSess string
lastPrmpt string
}
func newWakeupCapture() *wakeupCapture {
return &wakeupCapture{}
}
func (c *wakeupCapture) fire(sessionID, prompt string) {
c.mu.Lock()
c.lastSess = sessionID
c.lastPrmpt = prompt
c.mu.Unlock()
atomic.AddInt32(&c.calls, 1)
}
// Timer-driven tests use testing/synctest so time.AfterFunc is intercepted by
// synctest's fake clock. To advance fake time past a scheduled timer we
// time.Sleep in the test goroutine — under synctest, all bubble goroutines
// are then blocked and the runtime jumps fake time to the next pending event.
// synctest.Wait() afterwards ensures any goroutine the timer spawned has
// finished its work before the assertion.
func TestWakeupScheduler_FiresAfterDelay(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
log := newTestWakeupLogger(t)
cap := newWakeupCapture()
sched := newWakeupScheduler(log, cap.fire)
at := time.Now().Add(40 * time.Millisecond).UnixMilli()
sched.schedule("sess-1", "wake-prompt", at)
time.Sleep(100 * time.Millisecond)
synctest.Wait()
if got := atomic.LoadInt32(&cap.calls); got != 1 {
t.Fatalf("expected 1 fire, got %d", got)
}
cap.mu.Lock()
defer cap.mu.Unlock()
if cap.lastSess != "sess-1" || cap.lastPrmpt != "wake-prompt" {
t.Errorf("unexpected fire args: sess=%q prompt=%q", cap.lastSess, cap.lastPrmpt)
}
})
}
func TestWakeupScheduler_CancelPreventsFire(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
log := newTestWakeupLogger(t)
cap := newWakeupCapture()
sched := newWakeupScheduler(log, cap.fire)
at := time.Now().Add(50 * time.Millisecond).UnixMilli()
sched.schedule("sess-1", "p", at)
sched.cancel()
// Advance fake time past the would-be fire instant; nothing should run.
time.Sleep(200 * time.Millisecond)
synctest.Wait()
if got := atomic.LoadInt32(&cap.calls); got != 0 {
t.Errorf("expected 0 fires, got %d", got)
}
})
}
func TestWakeupScheduler_StaleTimestampDropped(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
log := newTestWakeupLogger(t)
cap := newWakeupCapture()
sched := newWakeupScheduler(log, cap.fire)
// Past timestamp: should not arm a timer at all.
sched.schedule("sess-1", "p", time.Now().Add(-time.Hour).UnixMilli())
time.Sleep(100 * time.Millisecond)
synctest.Wait()
if got := atomic.LoadInt32(&cap.calls); got != 0 {
t.Errorf("expected 0 fires, got %d", got)
}
})
}
func TestWakeupScheduler_RescheduleReplacesPrior(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
log := newTestWakeupLogger(t)
cap := newWakeupCapture()
sched := newWakeupScheduler(log, cap.fire)
// First schedule far in the future, then reschedule soon — only the
// second should fire, and only once.
sched.schedule("sess-1", "first", time.Now().Add(time.Hour).UnixMilli())
sched.schedule("sess-1", "second", time.Now().Add(40*time.Millisecond).UnixMilli())
time.Sleep(100 * time.Millisecond)
synctest.Wait()
if got := atomic.LoadInt32(&cap.calls); got != 1 {
t.Fatalf("expected 1 fire, got %d", got)
}
cap.mu.Lock()
defer cap.mu.Unlock()
if cap.lastPrmpt != "second" {
t.Errorf("expected last prompt %q, got %q", "second", cap.lastPrmpt)
}
})
}
// TestWakeupScheduler_StaleFireOnceDoesNotConsumeNewState reproduces the race
// where time.AfterFunc has already launched a fireOnce goroutine for the old
// timer before schedule() runs. The stale fireOnce must observe the gen
// mismatch and bail out, not consume the newly scheduled wakeup.
func TestWakeupScheduler_StaleFireOnceDoesNotConsumeNewState(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
log := newTestWakeupLogger(t)
cap := newWakeupCapture()
sched := newWakeupScheduler(log, cap.fire)
// Manually drive the race: arm a timer, capture its gen, then simulate
// a stale fire by calling fireOnce with the captured gen *after* a
// rescheduling has bumped gen.
sched.mu.Lock()
sched.gen++
staleGen := sched.gen
sched.sessionID = "old-sess"
sched.prompt = "old-prompt"
sched.mu.Unlock()
// Reschedule before stale fire executes.
sched.schedule("new-sess", "new-prompt", time.Now().Add(time.Hour).UnixMilli())
// Stale fire arrives now — should be a no-op.
sched.fireOnce(staleGen)
if got := atomic.LoadInt32(&cap.calls); got != 0 {
t.Errorf("stale fireOnce fired anyway: calls=%d", got)
}
// New schedule should still fire normally when its timer elapses.
sched.cancel()
sched.schedule("new-sess", "new-prompt", time.Now().Add(40*time.Millisecond).UnixMilli())
time.Sleep(100 * time.Millisecond)
synctest.Wait()
if got := atomic.LoadInt32(&cap.calls); got != 1 {
t.Fatalf("expected 1 fire after reschedule, got %d", got)
}
cap.mu.Lock()
defer cap.mu.Unlock()
if cap.lastSess != "new-sess" || cap.lastPrmpt != "new-prompt" {
t.Errorf("fired with wrong args: sess=%q prompt=%q", cap.lastSess, cap.lastPrmpt)
}
})
}
func TestWakeupScheduler_IgnoresMissingFields(t *testing.T) {
log := newTestWakeupLogger(t)
cap := newWakeupCapture()
sched := newWakeupScheduler(log, cap.fire)
// Missing each required field in turn — none should arm a timer.
sched.schedule("", "p", time.Now().Add(time.Minute).UnixMilli())
sched.schedule("sess", "", time.Now().Add(time.Minute).UnixMilli())
sched.schedule("sess", "p", 0)
if got := atomic.LoadInt32(&cap.calls); got != 0 {
t.Errorf("expected 0 fires, got %d", got)
}
}
func TestHandleWakeupEvent_SchedulesOnceBothFieldsArrive(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
a := newTestAdapter()
t.Cleanup(func() { _ = a.Close() })
cap := newWakeupCapture()
a.wakeup = newWakeupScheduler(a.logger, cap.fire)
at := time.Now().Add(40 * time.Millisecond).UnixMilli()
// Initial tool_call: only the toolName is known.
metaInitial := map[string]any{
"claudeCode": map[string]any{"toolName": "ScheduleWakeup"},
}
a.handleWakeupEvent("sess-1", "tc-1", metaInitial, nil, false)
// Mid-update: rawInput arrives with the prompt.
a.handleWakeupEvent("sess-1", "tc-1", metaInitial, map[string]any{
"prompt": "wake-now",
"delaySeconds": 60,
}, false)
// Verify nothing fired yet — scheduledFor still missing.
if got := atomic.LoadInt32(&cap.calls); got != 0 {
t.Fatalf("fired before scheduledFor known: calls=%d", got)
}
// Final update: scheduledFor arrives in meta.
metaWithResp := map[string]any{
"claudeCode": map[string]any{
"toolName": "ScheduleWakeup",
"toolResponse": map[string]any{"scheduledFor": float64(at)},
},
}
a.handleWakeupEvent("sess-1", "tc-1", metaWithResp, nil, false)
time.Sleep(100 * time.Millisecond)
synctest.Wait()
if got := atomic.LoadInt32(&cap.calls); got != 1 {
t.Fatalf("expected 1 fire, got %d", got)
}
cap.mu.Lock()
defer cap.mu.Unlock()
if cap.lastPrmpt != "wake-now" {
t.Errorf("prompt=%q, want %q", cap.lastPrmpt, "wake-now")
}
})
}
func TestHandleWakeupEvent_NonWakeupIgnored(t *testing.T) {
a := newTestAdapter()
t.Cleanup(func() { _ = a.Close() })
cap := newWakeupCapture()
a.wakeup = newWakeupScheduler(a.logger, cap.fire)
// Bash tool — should be ignored entirely.
meta := map[string]any{
"claudeCode": map[string]any{
"toolName": "Bash",
"toolResponse": map[string]any{"scheduledFor": float64(time.Now().Add(time.Hour).UnixMilli())},
},
}
a.handleWakeupEvent("sess-1", "tc-1", meta, map[string]any{"prompt": "x"}, false)
a.mu.Lock()
if _, present := a.pendingWakeups["tc-1"]; present {
t.Error("non-wakeup tool should not be tracked")
}
a.mu.Unlock()
if got := atomic.LoadInt32(&cap.calls); got != 0 {
t.Errorf("expected 0 fires, got %d", got)
}
}
func TestHandleWakeupEvent_TerminalCleansUpPending(t *testing.T) {
a := newTestAdapter()
t.Cleanup(func() { _ = a.Close() })
cap := newWakeupCapture()
a.wakeup = newWakeupScheduler(a.logger, cap.fire)
meta := map[string]any{
"claudeCode": map[string]any{"toolName": "ScheduleWakeup"},
}
a.handleWakeupEvent("sess-1", "tc-1", meta, nil, false)
a.mu.Lock()
_, present := a.pendingWakeups["tc-1"]
a.mu.Unlock()
if !present {
t.Fatal("expected pending wakeup before terminal")
}
// Terminal arrives without prompt or scheduledFor — should clean up.
a.handleWakeupEvent("sess-1", "tc-1", meta, nil, true)
a.mu.Lock()
_, present = a.pendingWakeups["tc-1"]
a.mu.Unlock()
if present {
t.Error("expected pending wakeup cleared on terminal")
}
if got := atomic.LoadInt32(&cap.calls); got != 0 {
t.Errorf("terminal-without-data should not fire, got %d calls", got)
}
}
// TestFireWakeup_SkipsOnSessionChange exercises fireWakeup's session-guard
// branch — when the adapter's current session has rotated away from the one
// the wakeup was scheduled for, the wakeup must drop instead of trying to
// drive an unrelated session.
// TestFireWakeup_SkipsOnSessionChange exercises fireWakeup's session-guard
// branch — when the adapter's current session has rotated away from the one
// the wakeup was scheduled for, the wakeup must drop instead of trying to
// drive an unrelated session.
//
// We can't observe Prompt directly (it returns early on nil acpConn without
// any side effect on the updates channel) so we use a.pendingContext as a
// canary: Prompt's first action under a.mu is to read-and-clear it. If the
// guard works, fireWakeup never spawns the goroutine that calls Prompt and
// the canary survives. If the guard regresses, Prompt runs and clears it.
func TestFireWakeup_SkipsOnSessionChange(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
a := newTestAdapter()
t.Cleanup(func() { _ = a.Close() })
// Adapter currently tracks a different session than the one the wakeup
// was scheduled against, and pendingContext holds a canary string.
const canary = "guard-canary"
a.mu.Lock()
a.sessionID = "new-sess"
a.pendingContext = canary
a.mu.Unlock()
a.fireWakeup("old-sess", "should-not-fire")
// Let any spawned goroutine run to completion. Inside synctest, after
// Wait() returns the bubble has settled and we can inspect state.
synctest.Wait()
a.mu.Lock()
got := a.pendingContext
a.mu.Unlock()
if got != canary {
t.Errorf("pendingContext=%q, want %q — Prompt was reached, guard regressed", got, canary)
}
})
}