From 7aff1f7d0c3167001521ddc7b917c3a28a215d9a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 21:44:40 -0400 Subject: [PATCH 1/3] e2e: the live standing check returns to the conversation that receives the firing The live firing scenario pressed ctrl+t while Home still selected the settled reminder exchange. That chord acts only on a conversation row, so the test stayed on Home and reported a delivered `said:` row as missing (#1344). The scenario now walks onto the existing conversation, opens it, and proves the transition before the standing pass. Two focused tests pin the boundaries the report implicated: a real session agent over the loopback wire delivers one standing update to the client lane, and the drawn `said:` row survives the reply its wake produces. Co-Authored-By: Claude Fable 5.1 --- internal/e2e/tui_e2e_test.go | 12 ++++--- internal/remote/tasklane_test.go | 58 ++++++++++++++++++++++++++++++++ internal/tui3/standing_test.go | 57 +++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go index 394fa55a68..dff5e3e598 100644 --- a/internal/e2e/tui_e2e_test.go +++ b/internal/e2e/tui_e2e_test.go @@ -765,10 +765,14 @@ func testFiringReachesThePerson(t *testing.T) { // Back into the conversation and wait. The window runs the same pass the // timer runs, every standing.Interval (five minutes), the first one an - // interval after launch. Answering the card already handed the keyboard - // back to the list; the new-chat chord opens a conversation composer. - r.keys("C-t") - time.Sleep(2500 * time.Millisecond) + // interval after launch. Answering the card hands the keyboard back to its + // settled exchange row, immediately below the conversation we opened. Walk + // onto that conversation and open it: ctrl+t acts only on a conversation + // row, so sending it from the exchange row silently left this test on Home + // while it looked there for a conversation-only firing row (#1344). + r.keys("Up") + r.keys("Enter") + r.waitFor(20*time.Second, say(t, "homeDoorWord")) // /status, while something stands: the derived `keeping watch` line, and the // `◦ 1 standing order` line at the foot of the task column. That count was a diff --git a/internal/remote/tasklane_test.go b/internal/remote/tasklane_test.go index df7f10b0cb..7c2bc9cb0d 100644 --- a/internal/remote/tasklane_test.go +++ b/internal/remote/tasklane_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/standing" ) // railAgent is a [fakeAgent] that carries the standing task lane the real @@ -157,6 +158,63 @@ func TestAHandStartedTaskReachesTheHostedRail(t *testing.T) { } } +// A FIRING USES THE REAL SESSION LANE OVER THE REAL WIRE. The scripted rail +// above proves that a task-shaped event can cross; this is the other producer +// of that lane, whose event has no task payload and is raised outside a turn. +func TestAStandingFiringReachesTheHostedConversation(t *testing.T) { + workspace := t.TempDir() + far, err := session.New(session.Config{ + Workspace: workspace, + Model: "stub/standing-wire", + APIKey: "fixture", + BaseURL: "http://127.0.0.1:1/v1", + System: "Test only.", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = far.Close() }) + + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: far, Workspace: workspace}, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + + lane, stop := loop.Client.Agent().WatchTaskUpdates() + t.Cleanup(stop) + // WatchTaskUpdates asks asynchronously so the surface loop never waits on a + // round trip. This synchronous repeat is the test's receipt that the far + // subscription exists before the firing; replacing it is the door's normal + // idempotent behaviour. + if _, err := loop.Client.call(context.Background(), MethodTaskWatch, nil); err != nil { + t.Fatalf("open the hosted standing lane: %v", err) + } + + item := standing.Item{ + ID: "water", + Words: "remind me in 1 minute to drink water", + Workspace: workspace, + } + runner := session.NewStandingRunner(session.Config{}, t.TempDir()) + if _, err := runner.Say(context.Background(), item, "Time to drink water!"); err != nil { + t.Fatalf("fire the standing item: %v", err) + } + + event := nextTask(t, lane) + if event.Kind != session.EventStandingUpdate || event.Standing == nil { + t.Fatalf("the lane carried %v, not a standing update", event.Kind) + } + if event.Standing.Item.ID != item.ID || event.Standing.Item.Words != item.Words { + t.Fatalf("the hosted row names %+v, want %+v", event.Standing.Item, item) + } + if event.Standing.Text != "Time to drink water!" { + t.Fatalf("the hosted row says %q", event.Standing.Text) + } +} + // A window that arrives after the work started still learns every row, because // the engine's subscription replays the roster as it opens. func TestAHostedRailOpenedLateStillGetsTheRoster(t *testing.T) { diff --git a/internal/tui3/standing_test.go b/internal/tui3/standing_test.go index 95cee38920..ed0897fbb7 100644 --- a/internal/tui3/standing_test.go +++ b/internal/tui3/standing_test.go @@ -740,6 +740,63 @@ func TestAFiringOffTheStandingLaneIsDrawnInTheConversation(t *testing.T) { } } +// firingReplyAgent carries both lanes involved in a live firing: the standing +// event that draws the news and the turn the session wakes to answer it. +type firingReplyAgent struct { + *taskFake + wakes chan (<-chan session.Event) +} + +func (f *firingReplyAgent) Wakes() <-chan (<-chan session.Event) { return f.wakes } + +// THE REPLY MAY FOLD ITS OWN WORK AND NEVER THE NEWS THAT WOKE IT. The row is +// present before the first reply event and remains exactly once after the turn +// settles, which pins both halves of the ordering contract. +func TestAStandingFiringStaysDrawnAfterItsReply(t *testing.T) { + agent := &firingReplyAgent{ + taskFake: &taskFake{ + fakeAgent: &fakeAgent{model: "m"}, + updates: make(chan session.Event, 8), + }, + wakes: make(chan (<-chan session.Event), 1), + } + a := newTestApp(agent) + a.width, a.height = 120, 24 + tasks, wakes := a.watchTasks(), a.watchWakes() + if tasks == nil || wakes == nil { + t.Fatal("the surface did not open both standing firing lanes") + } + + const words = "remind me in 1 minute to drink water" + row := standName(words) + " · said: Time to drink water!" + agent.updates <- session.Event{Kind: session.EventStandingUpdate, Standing: &session.StandingNotice{ + Item: standing.Item{ + ID: "water", + Words: words, + }, + Update: "fired", + Text: "Time to drink water!", + }} + drive(t, a, runCmd(tasks)...) + if body := standText(a); strings.Count(body, row) != 1 { + t.Fatalf("before the reply the firing row occurs %d times, want once:\n%s", strings.Count(body, row), body) + } + + agent.wakes <- woken( + text(session.EventTextDelta, "Drink some water now."), + session.Event{Kind: session.EventTurnDone}, + ) + drive(t, a, runCmd(wakes)...) + drive(t, a, frameMsg{}) + body := standText(a) + if !strings.Contains(body, "Drink some water now.") { + t.Fatalf("the firing's reply was not drawn:\n%s", body) + } + if got := strings.Count(body, row); got != 1 { + t.Fatalf("after the reply the firing row occurs %d times, want once:\n%s", got, body) + } +} + // AND A RUN THAT STOPPED ON SOMEBODY WEARS THE ACCENT, on the same lane and // with no turn to carry it either. func TestAFiringThatNeedsSomebodyIsDrawnWithTheAskGlyph(t *testing.T) { From 9dca11124407a2796531c489630a1a007f0780ac Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 22 Sep 2026 09:11:34 -0400 Subject: [PATCH 2/3] changelog: the entry for #1377 Co-Authored-By: Claude Fable 5.1 --- ...standing-check-returns-to-the-conversation.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/changes/unreleased/1377-live-standing-check-returns-to-the-conversation.md diff --git a/docs/changes/unreleased/1377-live-standing-check-returns-to-the-conversation.md b/docs/changes/unreleased/1377-live-standing-check-returns-to-the-conversation.md new file mode 100644 index 0000000000..e89bbe0170 --- /dev/null +++ b/docs/changes/unreleased/1377-live-standing-check-returns-to-the-conversation.md @@ -0,0 +1,16 @@ +--- +kind: internal +title: the live standing check returns to the conversation that receives the firing +surface: [chat, remote] +invalidates: + - "The live standing acceptance pressed `ctrl+t` while Home still selected the settled reminder exchange and assumed it had opened a chat. That chord acts only on a conversation row there, so the test stayed on Home and reported a delivered `said:` row as missing. It now reopens the existing conversation and proves that transition before waiting for the firing." +--- + +The standing event already crossed the hosted task-update wire, remained visible +through its reply, and reached the conversation exactly once. Focused tests now +pin those two boundaries directly. + +The end-to-end scenario moves from the settled reminder row onto its existing +conversation and waits for that conversation's footer before the five-minute +standing pass. Both the attended firing and the later closed-window drain are +read from the surfaces where a person actually receives them. From 8a6c444e584718d9f0fa28acad2956e9f190cabd Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 22 Sep 2026 09:19:40 -0400 Subject: [PATCH 3/3] changelog: the entry names its pull request Co-Authored-By: Claude Fable 5.1 --- .../1377-live-standing-check-returns-to-the-conversation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changes/unreleased/1377-live-standing-check-returns-to-the-conversation.md b/docs/changes/unreleased/1377-live-standing-check-returns-to-the-conversation.md index e89bbe0170..cc9f9a688d 100644 --- a/docs/changes/unreleased/1377-live-standing-check-returns-to-the-conversation.md +++ b/docs/changes/unreleased/1377-live-standing-check-returns-to-the-conversation.md @@ -1,5 +1,6 @@ --- kind: internal +pr: 1377 title: the live standing check returns to the conversation that receives the firing surface: [chat, remote] invalidates: