Skip to content

Commit 6799c94

Browse files
authored
Merge pull request #66 from helixml/feature/002228-unify-all-agent-message
Add e2e phases for the production prompt-queue path
2 parents 9546054 + 548da16 commit 6799c94

3 files changed

Lines changed: 253 additions & 3 deletions

File tree

crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.mod

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ require (
3636
github.com/anthropics/anthropic-sdk-go v1.12.0 // indirect
3737
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
3838
github.com/avast/retry-go/v4 v4.5.1 // indirect
39+
github.com/beorn7/perks v1.0.1 // indirect
3940
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
4041
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 // indirect
4142
github.com/bwmarrin/discordgo v0.28.1 // indirect
@@ -136,7 +137,7 @@ require (
136137
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
137138
github.com/hashicorp/go-version v1.9.0 // indirect
138139
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
139-
github.com/helixml/kodit v1.3.7 // indirect
140+
github.com/helixml/kodit v1.3.8 // indirect
140141
github.com/huandu/xstrings v1.5.0 // indirect
141142
github.com/infracloudio/msbotbuilder-go v0.2.5 // indirect
142143
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -171,6 +172,7 @@ require (
171172
github.com/lestrrat-go/jwx v1.1.7 // indirect
172173
github.com/lestrrat-go/option v1.0.0 // indirect
173174
github.com/lib/pq v1.10.9 // indirect
175+
github.com/libdns/cloudflare v0.2.2 // indirect
174176
github.com/libdns/libdns v1.1.1 // indirect
175177
github.com/mailgun/mailgun-go/v4 v4.9.1 // indirect
176178
github.com/mailru/easyjson v0.9.1 // indirect
@@ -190,6 +192,7 @@ require (
190192
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
191193
github.com/modern-go/reflect2 v1.0.2 // indirect
192194
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
195+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
193196
github.com/nats-io/jwt/v2 v2.5.5 // indirect
194197
github.com/nats-io/nats-server/v2 v2.10.14 // indirect
195198
github.com/nats-io/nats.go v1.38.0 // indirect
@@ -209,6 +212,10 @@ require (
209212
github.com/pkg/errors v0.9.1 // indirect
210213
github.com/pkoukk/tiktoken-go v0.1.6 // indirect
211214
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
215+
github.com/prometheus/client_golang v1.23.0 // indirect
216+
github.com/prometheus/client_model v0.6.2 // indirect
217+
github.com/prometheus/common v0.65.0 // indirect
218+
github.com/prometheus/procfs v0.17.0 // indirect
212219
github.com/redis/go-redis/v9 v9.18.0 // indirect
213220
github.com/robfig/cron/v3 v3.0.2-0.20210106135023-bc59245fe10e // indirect
214221
github.com/rs/zerolog v1.35.0 // indirect

crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.sum

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
165165
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
166166
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
167167
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
168+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
168169
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
169170
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
170171
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
@@ -536,6 +537,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
536537
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
537538
github.com/helixml/kodit v1.3.7 h1:5s1n1UOsEIlh4ZjNPHpS+uHs14CArzpYh/qj8fJt2ic=
538539
github.com/helixml/kodit v1.3.7/go.mod h1:NhT1+0j9LZk6u0mZzuMbyc/P/Rfheh8OYLUxANN9JTg=
540+
github.com/helixml/kodit v1.3.8 h1:bdAzZgdeFvoTdT+tht4G6drlkSsEdqAzFnReLC4RTW8=
541+
github.com/helixml/kodit v1.3.8/go.mod h1:NhT1+0j9LZk6u0mZzuMbyc/P/Rfheh8OYLUxANN9JTg=
539542
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
540543
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
541544
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
@@ -647,6 +650,8 @@ github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY
647650
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
648651
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
649652
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
653+
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
654+
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
650655
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
651656
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
652657
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -712,6 +717,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
712717
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
713718
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
714719
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
720+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
721+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
715722
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
716723
github.com/nats-io/jwt/v2 v2.5.5 h1:ROfXb50elFq5c9+1ztaUbdlrArNFl2+fQWP6B8HGEq4=
717724
github.com/nats-io/jwt/v2 v2.5.5/go.mod h1:ZdWS1nZa6WMZfFwwgpEaqBV8EPGVgOTDHN/wTbz0Y5A=
@@ -779,17 +786,25 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
779786
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
780787
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
781788
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
789+
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
790+
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
782791
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
783792
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
784793
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
785794
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
795+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
796+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
786797
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
787798
github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
788799
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
800+
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
801+
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
789802
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
790803
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
791804
github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
792805
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
806+
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
807+
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
793808
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
794809
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
795810
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=

crates/external_websocket_sync/e2e-test/helix-ws-test-server/main.go

Lines changed: 230 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,19 @@ type roundState struct {
122122
// filters Phase 10's synthetic ID out of this counter explicitly.
123123
spontaneousUserCreatedThreadCount int
124124
spontaneousUserCreatedThreadIDs []string
125+
126+
// Phases 16/17: PRODUCTION queue path (session-scoped prompt queue) against
127+
// a live Zed. Unlike the other phases (which drive the low-level
128+
// SendChatMessage primitive), these exercise EnqueueQueuedPrompt →
129+
// processPendingPromptsForSession → processPromptQueue / processInterruptPrompt
130+
// → sendQueuedPromptToSession — the path all production agent sends now use.
131+
// Queue prompts get generated request_ids (not "req-phaseN-"), so these
132+
// phases are driven by polling the store, not the completion switch.
133+
queueThreadID string // established thread the queue phases reuse
134+
phase16Deferred bool // interrupt=false was HELD while busy (no concurrent interaction)
135+
phase16Delivered bool // the deferred message was delivered as the next turn once idle
136+
phase17Interrupted bool // interrupt=true cancelled the running turn (interaction went interrupted)
137+
phase17Delivered bool // the interrupt message was delivered
125138
}
126139

127140
// phase15AddSample records a single message_added event tied to phase 15's
@@ -231,6 +244,11 @@ func (d *testDriver) syncEventCallback(sessionID string, syncMsg *types.SyncMess
231244
d.round.phase15ThreadID = acpThreadID
232245
log.Printf("[%s] Phase 15: Captured thread ID: %s", d.round.agentName, truncate(acpThreadID, 16))
233246
}
247+
// Capture the thread created for phase 16 (production queue path).
248+
if d.phase == 16 && d.round.queueThreadID == "" {
249+
d.round.queueThreadID = acpThreadID
250+
log.Printf("[%s] Phase 16: Captured thread ID: %s", d.round.agentName, truncate(acpThreadID, 16))
251+
}
234252
}
235253

236254
case "user_created_thread":
@@ -770,8 +788,16 @@ func (d *testDriver) advanceAfterCompletion(completedPhase int) {
770788
d.mu.Unlock()
771789
d.runPhase13()
772790
case 15:
773-
// Phase 15 completed normally — final phase of the round.
774-
d.advanceToNextRound()
791+
// Phase 15 done — advance to the production queue-path phases.
792+
d.mu.Lock()
793+
d.phase = 16
794+
d.mu.Unlock()
795+
d.runPhase16()
796+
case 16:
797+
// The phase-16 initial message (which established the queue thread) has
798+
// completed. Now run the queue defer + interrupt sub-tests, which drive
799+
// the production queue path and poll the store for outcomes.
800+
go d.runQueuePhases()
775801
}
776802
}
777803

@@ -1277,6 +1303,191 @@ func (d *testDriver) runPhase15() {
12771303
)
12781304
}
12791305

1306+
// runPhase16 establishes a thread for the production queue-path phases by
1307+
// sending an ordinary message. When it completes (case 16), runQueuePhases()
1308+
// drives the queue itself. Establishing the thread up front keeps the queue
1309+
// tests off the first-message boot-race path so they exercise the steady-state
1310+
// busy-defer / interrupt behaviour cleanly.
1311+
func (d *testDriver) runPhase16() {
1312+
agent := d.round.agentName
1313+
log.Printf("\n==================================================")
1314+
log.Printf(" [%s] PHASE 16: Production queue path — establish thread", agent)
1315+
log.Printf("==================================================")
1316+
d.startPhaseTimeout(16)
1317+
d.sendChatMessage("What is 6 + 6? Reply with just the number.", d.round.reqID("phase16"), agent)
1318+
}
1319+
1320+
// findInteractionByPromptID returns (interactionID, state) for the interaction
1321+
// the queue created from the given prompt-history entry, or ("","") if none yet.
1322+
func (d *testDriver) findInteractionByPromptID(promptID string) (string, string) {
1323+
for _, in := range d.store.GetAllInteractions() {
1324+
if in.PromptID == promptID {
1325+
return in.ID, string(in.State)
1326+
}
1327+
}
1328+
return "", ""
1329+
}
1330+
1331+
// waitInteractionStreaming polls until the interaction for promptID is actively
1332+
// streaming (waiting state with some assistant content) — the same precondition
1333+
// Phase 8 uses before interrupting, so the second message lands mid-turn rather
1334+
// than before the turn has started (cancelling a not-yet-started turn leaves the
1335+
// follow-up wedged). Returns true once streaming, false on timeout.
1336+
func (d *testDriver) waitInteractionStreaming(promptID string, timeout time.Duration) bool {
1337+
deadline := time.Now().Add(timeout)
1338+
for time.Now().Before(deadline) {
1339+
for _, in := range d.store.GetAllInteractions() {
1340+
if in.PromptID == promptID && in.ResponseMessage != "" {
1341+
return true
1342+
}
1343+
}
1344+
time.Sleep(300 * time.Millisecond)
1345+
}
1346+
return false
1347+
}
1348+
1349+
// waitInteractionState polls until the interaction for promptID reaches one of
1350+
// wantStates, returning true on success or false on timeout.
1351+
func (d *testDriver) waitInteractionState(promptID string, wantStates map[string]bool, timeout time.Duration) bool {
1352+
deadline := time.Now().Add(timeout)
1353+
for time.Now().Before(deadline) {
1354+
if _, st := d.findInteractionByPromptID(promptID); st != "" && wantStates[st] {
1355+
return true
1356+
}
1357+
time.Sleep(500 * time.Millisecond)
1358+
}
1359+
return false
1360+
}
1361+
1362+
// runQueuePhases drives the production send path against live Zed:
1363+
//
1364+
// Phase 16 (defer): enqueue interrupt=false while the agent is busy and assert
1365+
// it is HELD (no concurrent interaction) then delivered as the next turn once
1366+
// idle — the concurrent-mid-turn incident, now fixed.
1367+
// Phase 17 (interrupt): enqueue interrupt=true while busy and assert it cancels
1368+
// the running turn (interaction → interrupted) and its own message is delivered.
1369+
//
1370+
// Driven by polling the store because queue prompts get generated request_ids
1371+
// that don't flow through the phase-completion switch.
1372+
func (d *testDriver) runQueuePhases() {
1373+
agent := d.round.agentName
1374+
d.mu.Lock()
1375+
threadID := d.round.queueThreadID
1376+
d.mu.Unlock()
1377+
1378+
// Resolve the child session that owns the established thread — that's what we
1379+
// enqueue onto (its ZedThreadID drives sendQueuedPromptToSession).
1380+
var sessID string
1381+
for _, s := range d.store.GetAllSessions() {
1382+
if s.Metadata.ZedThreadID == threadID {
1383+
sessID = s.ID
1384+
break
1385+
}
1386+
}
1387+
if sessID == "" {
1388+
log.Printf("[%s] Phase 16: ABORT — no session found for queue thread %s", agent, truncate(threadID, 12))
1389+
os.Exit(1)
1390+
}
1391+
waiting := map[string]bool{"waiting": true}
1392+
complete := map[string]bool{"complete": true}
1393+
interrupted := map[string]bool{"interrupted": true}
1394+
1395+
// ---- Phase 16: busy-defer + deliver-when-idle (interrupt=false) ----
1396+
log.Printf("\n==================================================")
1397+
log.Printf(" [%s] PHASE 16: Queue busy-defer (interrupt=false)", agent)
1398+
log.Printf("==================================================")
1399+
d.startPhaseTimeout(16)
1400+
1401+
promptA, err := d.srv.EnqueueQueuedPrompt(sessID, "Count slowly from 1 to 40, writing one number per line with a short fact about each.", false)
1402+
if err != nil {
1403+
log.Printf("[%s] Phase 16: ABORT — enqueue A failed: %v", agent, err)
1404+
os.Exit(1)
1405+
}
1406+
if !d.waitInteractionState(promptA, waiting, 15*time.Second) {
1407+
log.Printf("[%s] Phase 16: ABORT — turn A never became waiting", agent)
1408+
os.Exit(1)
1409+
}
1410+
// Wait until A is actively streaming so the session is genuinely busy when we
1411+
// enqueue B (avoids racing a fast turn that completes before B is queued).
1412+
if !d.waitInteractionStreaming(promptA, 30*time.Second) {
1413+
log.Printf("[%s] Phase 16: ABORT — turn A never started streaming", agent)
1414+
os.Exit(1)
1415+
}
1416+
// Agent is now busy on A. Enqueue B (interrupt=false): must be HELD.
1417+
promptB, err := d.srv.EnqueueQueuedPrompt(sessID, "Now also mention whales.", false)
1418+
if err != nil {
1419+
log.Printf("[%s] Phase 16: ABORT — enqueue B failed: %v", agent, err)
1420+
os.Exit(1)
1421+
}
1422+
if inID, _ := d.findInteractionByPromptID(promptB); inID != "" {
1423+
log.Printf("[%s] Phase 16: FAIL — B was dispatched concurrently (interaction %s) instead of deferred", agent, truncate(inID, 12))
1424+
} else {
1425+
d.mu.Lock()
1426+
d.round.phase16Deferred = true
1427+
d.mu.Unlock()
1428+
log.Printf("[%s] Phase 16: ✅ interrupt=false HELD while busy (no concurrent interaction)", agent)
1429+
}
1430+
// Once A completes, the production completion handler drains the queue and B
1431+
// is delivered as the next turn.
1432+
if d.waitInteractionState(promptB, complete, 75*time.Second) {
1433+
d.mu.Lock()
1434+
d.round.phase16Delivered = true
1435+
d.mu.Unlock()
1436+
log.Printf("[%s] Phase 16: ✅ deferred message delivered as next turn once idle", agent)
1437+
} else {
1438+
log.Printf("[%s] Phase 16: FAIL — deferred message never delivered", agent)
1439+
}
1440+
1441+
// ---- Phase 17: interrupt (interrupt=true cancels + delivers) ----
1442+
d.mu.Lock()
1443+
d.phase = 17
1444+
d.mu.Unlock()
1445+
log.Printf("\n==================================================")
1446+
log.Printf(" [%s] PHASE 17: Queue interrupt (interrupt=true)", agent)
1447+
log.Printf("==================================================")
1448+
d.startPhaseTimeout(17)
1449+
1450+
promptX, err := d.srv.EnqueueQueuedPrompt(sessID, "Count slowly from 1 to 60, one number per line, with a short sentence about each.", false)
1451+
if err != nil {
1452+
log.Printf("[%s] Phase 17: ABORT — enqueue X failed: %v", agent, err)
1453+
os.Exit(1)
1454+
}
1455+
if !d.waitInteractionState(promptX, waiting, 15*time.Second) {
1456+
log.Printf("[%s] Phase 17: ABORT — turn X never became waiting", agent)
1457+
os.Exit(1)
1458+
}
1459+
// Interrupt only once X is actively streaming (cancelling a not-yet-started
1460+
// turn leaves the follow-up wedged — same precondition Phase 8 relies on).
1461+
if !d.waitInteractionStreaming(promptX, 30*time.Second) {
1462+
log.Printf("[%s] Phase 17: ABORT — turn X never started streaming", agent)
1463+
os.Exit(1)
1464+
}
1465+
// Interrupt while X is streaming: cancel-then-send via processInterruptPrompt.
1466+
promptY, err := d.srv.EnqueueQueuedPrompt(sessID, "Stop counting — just say 'done'.", true)
1467+
if err != nil {
1468+
log.Printf("[%s] Phase 17: ABORT — enqueue Y (interrupt) failed: %v", agent, err)
1469+
os.Exit(1)
1470+
}
1471+
if d.waitInteractionState(promptX, interrupted, 30*time.Second) {
1472+
d.mu.Lock()
1473+
d.round.phase17Interrupted = true
1474+
d.mu.Unlock()
1475+
log.Printf("[%s] Phase 17: ✅ interrupt cancelled the running turn (X → interrupted)", agent)
1476+
} else {
1477+
log.Printf("[%s] Phase 17: FAIL — running turn X was not interrupted", agent)
1478+
}
1479+
if d.waitInteractionState(promptY, complete, 60*time.Second) {
1480+
d.mu.Lock()
1481+
d.round.phase17Delivered = true
1482+
d.mu.Unlock()
1483+
log.Printf("[%s] Phase 17: ✅ interrupt message delivered", agent)
1484+
} else {
1485+
log.Printf("[%s] Phase 17: FAIL — interrupt message never delivered", agent)
1486+
}
1487+
1488+
d.advanceToNextRound()
1489+
}
1490+
12801491
// --- Per-round validation ---
12811492

12821493
func (d *testDriver) validateRound() roundResult {
@@ -1812,6 +2023,21 @@ func (d *testDriver) validateRound() roundResult {
18122023
}
18132024
}
18142025

2026+
// Phase 16: production queue path — busy-defer + deliver-when-idle.
2027+
if !d.round.phase16Deferred {
2028+
errors = append(errors, "Phase 16: interrupt=false was NOT held while busy (concurrent dispatch)")
2029+
}
2030+
if !d.round.phase16Delivered {
2031+
errors = append(errors, "Phase 16: deferred queue message was never delivered once idle")
2032+
}
2033+
// Phase 17: production queue path — interrupt cancels + delivers.
2034+
if !d.round.phase17Interrupted {
2035+
errors = append(errors, "Phase 17: interrupt=true did NOT cancel the running turn")
2036+
}
2037+
if !d.round.phase17Delivered {
2038+
errors = append(errors, "Phase 17: interrupt queue message was never delivered")
2039+
}
2040+
18152041
// --- SUMMARY ---
18162042
passed := len(errors) == 0
18172043
if !passed {
@@ -1836,6 +2062,8 @@ func (d *testDriver) validateRound() roundResult {
18362062
log.Printf("[%s] Phase 13: Helix-initiated cancel - PASSED", agent)
18372063
log.Printf("[%s] Phase 14: Cancel no-op - PASSED", agent)
18382064
log.Printf("[%s] Phase 15: Streaming patches arrive incrementally - PASSED", agent)
2065+
log.Printf("[%s] Phase 16: Queue busy-defer (production path) - PASSED", agent)
2066+
log.Printf("[%s] Phase 17: Queue interrupt (production path) - PASSED", agent)
18392067
}
18402068

18412069
totalCompletions := 0

0 commit comments

Comments
 (0)