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 000000000..cc9f9a688 --- /dev/null +++ b/docs/changes/unreleased/1377-live-standing-check-returns-to-the-conversation.md @@ -0,0 +1,17 @@ +--- +kind: internal +pr: 1377 +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. diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go index 394fa55a6..dff5e3e59 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 df7f10b0c..7c2bc9cb0 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 95cee3892..ed0897fbb 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) {