From ea8a4cd8bb9830d5d67f63ee163a89b100695e52 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 14 Sep 2026 06:27:15 -0400 Subject: [PATCH 1/3] session: a card for a decision you already made is not asked again when you come back to the tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn's backlog is every event that turn has sent, kept so a surface arriving mid-turn reads the work from its first line. A CARD in it is the one event that is not a report of something that happened but a question about something that has not — and the replay handed it over verbatim, whatever had since been decided. So a person who approved a task, looked at another tab and came back was asked to approve it again, on every return, for as long as that turn ran; and because a question takes the keyboard where it is drawn, the conversation underneath it could not be scrolled while it stood. Both symptoms, one cause. The replay now consults the answer. [eventHub.attach] is handed what is still being asked — the banked words, read off a.mu at the moment of the attach, which is where a question goes on the book when it is raised and comes off it the moment it is answered or withdrawn — and leaves out a card whose question is no longer one. The backlog stays a faithful log of the turn; what is filtered is the REPLAY. [questionAsked] names the question a card put in front of somebody, in the lanes' own keys, for consent, a task proposal, a standing card, a harness offer and a connect ask alike. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/manual/chat/questions.md | 22 +++++++ internal/session/agent.go | 27 +++++++- internal/session/attach_test.go | 103 +++++++++++++++++++++++++++++- internal/session/question.go | 76 ++++++++++++++++++++++ 4 files changed, 223 insertions(+), 5 deletions(-) diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md index 33452c94df..18de0f964f 100644 --- a/internal/manual/chat/questions.md +++ b/internal/manual/chat/questions.md @@ -796,6 +796,28 @@ question is what is cut and they stay. Widen the window, or open the conversation: the transcript and the record always have the whole line. +## You answered it, then switched tab and came back — is it asked again? + +**No.** An answer is the conversation's, not the screen's. Leave a tab while a +reply is still running, come back to it, and what you get is the conversation as +it stands: the work that has happened, and only the questions still waiting on +you. A decision you already made is not put to you a second time, and neither is +one somebody answered in another window or one the clock took. + +What you will see of the answered one is nothing at all, once the receipt's half +minute has passed — that line is news, and coming back later is later. The +decision itself is in the transcript and in `decisions.jsonl` for good. + +**A question you have NOT answered is still there when you come back**, wherever +you went — another tab, home, the tasks place. That is the point of leaving it: +nothing about the work moves while it waits, and the chip in the status line +counts it from every page. + +This was wrong once and it was worth its own line here: a task-start question +approved mid-reply came back on every return to the tab, and because a question +takes the keyboard where it is drawn, the conversation underneath it could not be +scrolled while it stood. Both were the same defect, and both are gone. + ## A question that stops needing you Sometimes the thing a question was about goes away — the turn moved on, the plan diff --git a/internal/session/agent.go b/internal/session/agent.go index 462981ee9a..b8cd7dc711 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1030,9 +1030,13 @@ func (a *Agent) Attach() (events <-chan Event, running bool, stop func()) { return nil, false, func() {} } hub := a.hub + // AND WHAT IS STILL BEING ASKED, taken under this same lock rather than read + // through the map afterwards: the reply's cards are filtered against it + // ([eventHub.attach]), and a.questionWords belongs to a.mu. + asking := a.stillAskedLocked() a.mu.Unlock() - stream, live := hub.attach() + stream, live := hub.attach(asking) if !live { // The turn ended between the two locks. Its channel is already closed, // and saying "not running" is the honest answer to a question that was @@ -1076,7 +1080,7 @@ func (a *Agent) AttachReplay() (entries []DisplayEntry, events <-chan Event, sto // running under a.mu before it closes the hub, and we hold a.mu. The // defensive arm keeps the honest answer anyway — a dead stream would hang // the caller where the full record answers them. - stream, live := a.hub.attach() + stream, live := a.hub.attach(a.stillAskedLocked()) if !live { return shapeEntries(a.messages, a.file), nil, func() {} } @@ -3512,11 +3516,25 @@ func (h *eventHub) subscribe() <-chan Event { // [eventHub.send]'s own reason — an eventStream send is an append and a signal, // never a wait. // +// A CARD FOR A DECISION SOMEBODY HAS ALREADY MADE IS NOT REPLAYED, and asking +// is what says which those are — the questions still waiting on somebody, as +// [Agent.stillAskedLocked] reads them off the book at the moment of the attach. +// The backlog is a faithful log of the turn and stays one; what is filtered is the +// REPLAY, because a card is the one event in a turn that is not a report of +// something that happened but a question about something that has not. Without +// this, a person who approved a task and then looked at another tab was asked +// the same question again every time they came back, for as long as the turn ran +// — and the question, which takes the keyboard where it is drawn, made the +// conversation behind it unscrollable with it. +// +// A nil map filters nothing, which is the honest answer for a caller that cannot +// say what is open: every card replays, exactly as it did before this existed. +// // false is a hub that has already closed, and the stream is closed with it: the // turn whose events it would carry is over, and a caller is owed that answer // rather than a channel that never ends. It is the same shape [taskRoom.join] // answers a landed node with. -func (h *eventHub) attach() (*eventStream, bool) { +func (h *eventHub) attach(asking map[string]bool) (*eventStream, bool) { stream := newEventStream() if h == nil { stream.close() @@ -3530,6 +3548,9 @@ func (h *eventHub) attach() (*eventStream, bool) { } h.foldedLocked() for _, event := range h.backlog { + if key, asks := questionAsked(event); asks && !asking[key] { + continue + } stream.send(event) } h.subscribers = append(h.subscribers, stream) diff --git a/internal/session/attach_test.go b/internal/session/attach_test.go index 7fa35fc950..d581f6135c 100644 --- a/internal/session/attach_test.go +++ b/internal/session/attach_test.go @@ -553,14 +553,14 @@ func TestABacklogFoldedAcrossTwoAttachesStillSpellsTheWholeAnswer(t *testing.T) } send("the ", "answer ") - early, running := hub.attach() + early, running := hub.attach(nil) if !running { t.Fatal("attach to a live hub said nothing was running") } // The run CONTINUES past the attach, into the same backlog entry the first // reader was just handed. send("so ", "far", ", and ", "the rest") - late, running := hub.attach() + late, running := hub.attach(nil) if !running { t.Fatal("the second attach to a live hub said nothing was running") } @@ -636,3 +636,102 @@ func waitClosed(t *testing.T, events <-chan Event, what string) { } } } + +// ── a card for a decision already made ────────────────────────────────────── +// +// A turn's backlog is replayed to whoever attaches next, and a CARD in it is the +// one event that is not a report of something that happened but a question about +// something that has not. These two hold both halves of the one rule: a question +// somebody has answered is not asked again, and a question nobody has answered +// still is. + +// attachedLate is one surface arriving at a turn already under way, reading the +// replay it is handed and nothing more. It is [Agent.AttachReplay]'s own two +// lines — the hub, and what is still being asked, read under one hold of a.mu — +// without the transcript, which these tests are not about. +func attachedLate(t *testing.T, agent *Agent) []Event { + t.Helper() + agent.mu.Lock() + hub := agent.hub + stream, live := hub.attach(agent.stillAskedLocked()) + agent.mu.Unlock() + if !live { + t.Fatal("attaching to the turn said nothing was running") + } + // The hub is closed so the replay ends: what a late reader is handed is + // everything the backlog held, and nothing is left waiting on a channel. + hub.close() + return collect(t, stream.out) +} + +// TestAnAnsweredProposalIsNotAskedAgainOfASurfaceThatArrivesLater is the defect +// the owner met: approve a task, look at another tab, come back, and be asked +// the same question again — every time, for as long as the turn ran. The answer +// was recorded and the engine knew it; the REPLAY did not consult it, so the +// card that raised the question went out again as though nobody had decided. +func TestAnAnsweredProposalIsNotAskedAgainOfASurfaceThatArrivesLater(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.AskConsent = true + config.TaskAutoApproveSeconds = 15 + }) + heldClock(agent) + ran := make(ranNodes, 2) + stubbedGraph(agent, func(node *TaskNode) { + ran <- node + node.graph.complete(node, TaskDone) + }) + events := watched(agent) + + held := holdProposal(t, agent, proposalArgs("Port the resume picker")) + card := nextTurnEvent(t, events, EventTaskProposal) + + // THE PERSON SAYS YES, through the one door every answer on this surface + // goes through, and the reply that proposed the work then arrives whole. + if err := agent.ResolveQuestion(Answer{Kind: QuestionTask, ID: card.Task.ID, Key: "1"}); err != nil { + t.Fatalf("answering the proposal: %v", err) + } + held.hold.Release() + if result := held.await(t); result.isError { + t.Fatalf("the approved proposal answered %+v", result) + } + if open := agent.OpenQuestions(); len(open) != 0 { + t.Fatalf("OpenQuestions() = %+v after the answer, want none", open) + } + + for _, event := range attachedLate(t, agent) { + if event.Kind == EventTaskProposal { + t.Fatalf("the replay asked about task %d again after it was approved", event.Task.ID) + } + } + ran.await(t) +} + +// TestAProposalNobodyAnsweredIsStillAskedOfASurfaceThatArrivesLater is the other +// half, and it is the one that keeps the fix from being a deletion: a card still +// waiting on somebody is exactly what a surface arriving mid-turn must be handed, +// or a person who opened a second tab while a question stood would come back to a +// conversation that had stopped and said nothing about why. +func TestAProposalNobodyAnsweredIsStillAskedOfASurfaceThatArrivesLater(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.AskConsent = true + config.TaskAutoApproveSeconds = 15 + }) + heldClock(agent) + stubbedGraph(agent, func(node *TaskNode) { node.graph.complete(node, TaskDone) }) + events := watched(agent) + + held := holdProposal(t, agent, proposalArgs("Port the resume picker")) + card := nextTurnEvent(t, events, EventTaskProposal) + + asked := false + for _, event := range attachedLate(t, agent) { + if event.Kind == EventTaskProposal && event.Task != nil && event.Task.ID == card.Task.ID { + asked = true + } + } + if !asked { + t.Fatal("the replay left out a proposal nobody has answered") + } + held.hold.Withdraw() + held.await(t) +} diff --git a/internal/session/question.go b/internal/session/question.go index 5aab8da05a..0305a94d85 100644 --- a/internal/session/question.go +++ b/internal/session/question.go @@ -1416,6 +1416,82 @@ func questionToken(kind QuestionKind, token string) string { return string(kind) + ":" + token } +// questionAsked names the question one event on a TURN'S STREAM put in front of +// somebody, and false for an event that asks nothing. +// +// IT EXISTS FOR THE REPLAY AND FOR NOTHING ELSE ([eventHub.attach]). A turn's +// backlog is every event that turn has sent, kept verbatim so a surface arriving +// mid-turn reads the work from its first line — and a card is the one kind of +// event in it that is not a report of something that happened but a QUESTION +// about something that has not. A decision somebody already made must not be +// replayed as one still waiting for them, so the replay asks this which question +// a card was, and [Agent.askingLocked] whether that question is still open. +// +// THE KEYS ARE THE LANES' OWN AND ARE NEVER GUESSED. Each case names the field +// its lane's question is keyed by — consent's id, the proposal's node id, the +// standing item's id inside its card, the harness offer's id, connect's string — +// and each is the same token the lane's builder further down this file gives the +// question it banks, so the two cannot mean different things. A kind missing +// from this list replays exactly as it always did, which is the safe half of +// being wrong about one. +func questionAsked(event Event) (string, bool) { + switch event.Kind { + case EventConsentRequest: + if event.ID == 0 { + return "", false + } + return questionToken(QuestionConsent, strconv.FormatUint(event.ID, 10)), true + + case EventTaskProposal: + if event.Task == nil || event.Task.ID == 0 { + return "", false + } + return questionToken(QuestionTask, strconv.FormatUint(event.Task.ID, 10)), true + + case EventStandingProposal: + if event.Standing == nil || event.Standing.ID == 0 { + return "", false + } + return questionToken(QuestionStanding, strconv.FormatUint(event.Standing.ID, 10)), true + + case EventHarnessOffer: + if event.ID == 0 { + return "", false + } + return questionToken(QuestionHarness, strconv.FormatUint(event.ID, 10)), true + + case EventConnectAsk: + id := strings.TrimSpace(event.ConnectID) + if id == "" { + return "", false + } + return questionToken(QuestionConnect, id), true + } + return "", false +} + +// stillAskedLocked is every question this session is still waiting on somebody for, +// as the keys [questionAsked] names a card by. The caller holds a.mu. +// +// IT IS THE BANKED WORDS AND NOT A SECOND LIST, which is the whole reason it can +// be read here: [Agent.rememberQuestion] puts a question on that book as it is +// raised and [Agent.claimQuestionLocked] takes it off the moment it is answered +// or withdrawn, under this same lock — so "still on the book" is exactly "still +// a question", with no lock of anybody else's to take and no reconciliation to +// get wrong. [Agent.OpenQuestions] walks the lanes themselves and takes three +// locks doing it, which is the reading a surface asks for and not one an attach +// may make while it holds this one. +func (a *Agent) stillAskedLocked() map[string]bool { + if len(a.questionWords) == 0 { + return nil + } + asking := make(map[string]bool, len(a.questionWords)) + for key := range a.questionWords { + asking[key] = true + } + return asking +} + // rememberQuestion banks the words of a question this session has just put, and // answers the func that forgets them. // From 20fc1fee7e263ecc010683a8ab7e1d7d0fd15cd8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 14 Sep 2026 06:35:01 -0400 Subject: [PATCH 2/3] session, tui3: the card comes back with your answer under it, not with the question again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first half of this change left the settled card out of a turn's replay altogether, and that traded one lie for another: the surface reads a propose_task call whose card never arrived as a call that was refused (task.go's [app.refuseFormingCard]), so coming back to the tab said `not started · the call was refused` over work that was running. So the engine states the outcome instead of the replay swallowing the question. [taskWait.decided] restates the proposal with the answer stamped on it the moment anybody settles it — a person, another window, or the countdown — beside the restatement [taskWait.withdraw] already made for the other ending, and at the one place both roads pass through. A card that states its own outcome is not asking anything, so the replay keeps it and leaves the open one out. The surface draws it as what it is: the assignment with the answer under it and no question raised. [taskVerdict] is the one reading of approved-and-redirect that the window whose key answered and the window replaying a decision both take. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/session/attach_test.go | 21 ++++++- internal/session/question.go | 8 +++ internal/session/task.go | 34 ++++++++++- internal/session/task_contract.go | 17 ++++++ internal/tui3/task.go | 58 ++++++++++++++++--- internal/tui3/taskdecided_test.go | 95 +++++++++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 internal/tui3/taskdecided_test.go diff --git a/internal/session/attach_test.go b/internal/session/attach_test.go index d581f6135c..ebc6a11bb7 100644 --- a/internal/session/attach_test.go +++ b/internal/session/attach_test.go @@ -698,10 +698,27 @@ func TestAnAnsweredProposalIsNotAskedAgainOfASurfaceThatArrivesLater(t *testing. t.Fatalf("OpenQuestions() = %+v after the answer, want none", open) } + // THE REPLAY CARRIES THE ASSIGNMENT WITH THE ANSWER ON IT, ONCE. The open + // card — the one that asks — is left out; the restatement takes its place, so + // a surface arriving now draws what was decided and has nothing to answer. + cards := 0 for _, event := range attachedLate(t, agent) { - if event.Kind == EventTaskProposal { - t.Fatalf("the replay asked about task %d again after it was approved", event.Task.ID) + if event.Kind != EventTaskProposal { + continue } + cards++ + if event.Task == nil || event.Task.Decided == nil { + t.Fatalf("the replay asked about task %d again after it was approved: %+v", card.Task.ID, event.Task) + } + if !event.Task.Decided.Approved { + t.Fatalf("the replayed card says the proposal was declined: %+v", event.Task.Decided) + } + if !event.Task.Deadline.IsZero() { + t.Fatal("a decided card still carries a clock") + } + } + if cards != 1 { + t.Fatalf("the replay carried %d cards for one proposal, want the settled one alone", cards) } ran.await(t) } diff --git a/internal/session/question.go b/internal/session/question.go index 0305a94d85..c8fb589371 100644 --- a/internal/session/question.go +++ b/internal/session/question.go @@ -1446,6 +1446,14 @@ func questionAsked(event Event) (string, bool) { if event.Task == nil || event.Task.ID == 0 { return "", false } + if event.Task.Decided != nil || event.Task.Withdrawn != "" { + // A CARD THAT STATES ITS OWN OUTCOME IS NOT ASKING ANYTHING. The + // proposal is the one lane that restates its card when it is settled + // ([taskWait.decided], [taskWait.withdraw]), and that restatement is + // what the replay carries in the open card's place: the assignment, + // with the answer under it. + return "", false + } return questionToken(QuestionTask, strconv.FormatUint(event.Task.ID, 10)), true case EventStandingProposal: diff --git a/internal/session/task.go b/internal/session/task.go index db0c1fb6f3..a3c8490f45 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -1115,6 +1115,7 @@ func (w *taskWait) run(ctx context.Context, expiry <-chan time.Time, stopTimer f for { select { case answer := <-w.question.answer: + w.decided(answer) w.settled <- taskSettled{answer: answer} return case <-hold: @@ -1131,7 +1132,9 @@ func (w *taskWait) run(ctx context.Context, expiry <-chan time.Time, stopTimer f } // Silence is a yes, and it is a yes with no redirect: the person said // nothing, so nothing is appended to the brief. - w.settled <- taskSettled{answer: TaskAnswer{Approved: true}} + silent := TaskAnswer{Approved: true} + w.decided(silent) + w.settled <- taskSettled{answer: silent} return case <-ctx.Done(): w.agent.forgetTask(w.id) @@ -1143,6 +1146,35 @@ func (w *taskWait) run(ctx context.Context, expiry <-chan time.Time, stopTimer f } } +// decided restates the card with the answer stamped on it, and it is the SAME +// rebroadcast [taskWait.withdraw] makes for the other ending — the one place a +// proposal stops being a question is the one place that says so out loud. +// +// IT IS HERE AND NOT AT EITHER DOOR because both doors arrive here: a person's +// answer comes off the wait's own channel and the countdown's silence is +// manufactured a few lines above, and a restatement written at [Agent.ResolveTask] +// would be a card that says nothing when the clock decides. +// +// WHAT IT IS FOR IS THE REPLAY ([TaskNotice.Decided]). A window watching this +// turn has already drawn its own verdict from the key it pressed; what this +// rebroadcast changes is what the turn's backlog hands the NEXT surface to +// attach, which is the assignment with an answer under it rather than the +// question a second time. The deadline goes with it for [taskWait.withdraw]'s +// reason: nothing is counting any more. +func (w *taskWait) decided(answer TaskAnswer) { + a := w.agent + a.mu.Lock() + hub := a.hub + notice := w.question.notice + a.mu.Unlock() + if hub == nil { + return + } + notice.Deadline = time.Time{} + notice.Decided = &answer + hub.send(Event{Kind: EventTaskProposal, Tool: "propose_task", Task: ¬ice}) +} + // answer is what the wait came to, waiting for it if it has not come to // anything yet. The wait watches the turn's own context, so a turn that ends // under the question ends this too. diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index be9dce07ca..1a8b631554 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -488,6 +488,23 @@ type TaskNotice struct { // the card with it — whatever its own clock has drawn by then — because // nothing was started and nothing is waiting on the card any more. Withdrawn string + // Decided is what somebody said about this proposal, on the one rebroadcast + // that goes out the moment anybody does — the person, another window, or the + // countdown — and nil while it is still a question. + // + // IT EXISTS SO A CARD CAN BE REPLAYED WITHOUT BEING ASKED AGAIN. A turn's + // backlog is handed whole to whoever attaches next ([eventHub.attach]), and + // the card that raised a question is the one event in it that is not a report + // of something that happened. The open card is left out of that replay once + // its question is settled; this one goes in its place, so a person who + // approved a task, looked at another tab and came back reads the assignment + // with their own answer under it rather than the question a second time. + // + // A surface draws the verdict from Approved and Redirect together, which is + // the same reading the window that answered already made of its own key. The + // CLOCK's own wording is that window's and is not restated here: what is true + // afterwards is that the work was approved. + Decided *TaskAnswer // ModelOptions is the shortlist a `model` argument raised that fits more // than one model this install has (taskmodel.go). It is empty for every diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 6b8a0601b9..5c817a67a5 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -1223,6 +1223,25 @@ func (a *app) proposeTask(ev session.Event) { } else { a.entries = append(a.entries, entry{kind: entryTask, turn: a.turn, card: card}) } + // A CARD THAT ARRIVES ALREADY DECIDED IS NOT ASKED, and this is the whole of + // what a surface joining a turn late does with one. The engine restates a + // proposal the moment anybody settles it (session's [TaskNotice.Decided]) and + // leaves the open card out of the replay in its favour, so what lands here is + // the assignment with an answer already on it: the person approved this, went + // to another tab and came back, and what they are owed is what they decided — + // never the question again. + // + // IT IS ONLY EVER THE CREATE PATH. A window that already holds this card has + // been watching all along and has drawn its own verdict from the key it + // pressed or the clock it ran ([app.taskAnswered]); the branch above leaves + // that drawing alone. + if notice.Decided != nil { + card.verdict = taskVerdict(notice.Decided.Approved, notice.Decided.Redirect) + card.answer = taskAnswerWord(notice.Decided.Approved) + a.markCardStale(card) + a.touch() + return + } // AND THE QUESTION IS RAISED FROM HERE TOO, dressed with the lane's own two // hands. It arrives on the questions lane as well and the block replaces by // token, so whichever gets here first draws and the second is the same @@ -1234,6 +1253,36 @@ func (a *app) proposeTask(ev session.Event) { a.touch() } +// taskVerdict is the word a settled proposal keeps, from what was decided about +// it. It is ONE READING used twice — by the window whose key answered +// ([app.taskAnswered]) and by a window replaying a card the engine says was +// already settled — because a verdict spelled in two places is two accounts of +// one decision the first time either moves. +func taskVerdict(approved bool, redirect string) string { + switch { + case approved && strings.TrimSpace(redirect) != "": + return taskRedirectWord + case approved: + return taskApprovedWord + default: + return taskDeclinedWord + } +} + +// taskAnswerWord is the label the answer wears on a settled card, for the answer +// the engine says was taken. It is read off the proposal's own options rather +// than spelled here, so a replayed card and the card somebody answered say the +// same word. +func taskAnswerWord(approved bool) string { + for _, option := range session.AnswerOptions(session.QuestionTask) { + action, ok := session.AnswerFromKey(session.QuestionTask, option.Key) + if ok && action.Task.Approved == approved { + return strings.TrimSpace(option.Label) + } + } + return "" +} + // taskShown is one proposal as the block holds it: the engine's own question // object, plus the two things it cannot carry. // @@ -1492,14 +1541,7 @@ func (a *app) taskAnswered(id uint64, answer session.Answer) session.Answer { word = strings.TrimSpace(option.Label) } } - switch { - case approve && redirect != "": - card.verdict = taskRedirectWord - case approve: - card.verdict = taskApprovedWord - default: - card.verdict = taskDeclinedWord - } + card.verdict = taskVerdict(approve, redirect) card.answer = word // A YES OPENS THE FORMING BLOCK. The task the card names does not exist // until its first update arrives, and the person who just said yes is owed diff --git a/internal/tui3/taskdecided_test.go b/internal/tui3/taskdecided_test.go new file mode 100644 index 0000000000..6b2dd6ec72 --- /dev/null +++ b/internal/tui3/taskdecided_test.go @@ -0,0 +1,95 @@ +package tui3 + +// A PROPOSAL CARD THAT ARRIVES ALREADY DECIDED. The engine restates a proposal +// the moment anybody settles it and leaves the open card out of a turn's replay +// in its favour (session's [TaskNotice.Decided]), so this is what a window that +// was looking at another tab is handed when it comes back: the assignment, with +// the answer underneath it, and nothing to answer. + +import ( + "testing" + + "github.com/Agent-Field/aforge-v2/internal/session" +) + +// decidedProposal is one restated card, as the turn's replay carries it. +func decidedProposal(id uint64, answer session.TaskAnswer) session.Event { + return session.Event{Kind: session.EventTaskProposal, Tool: "propose_task", Task: &session.TaskNotice{ + ID: id, + Title: "Port the resume picker", + Summary: "two lines the person reads", + Brief: "the whole brief", + Decided: &answer, + }} +} + +// settledCard is the proposal card this window is drawing, and false where it +// has none. +func settledCard(a *app, id uint64) (*taskCard, bool) { + for i := range a.entries { + if a.entries[i].kind == entryTask && a.entries[i].card != nil && a.entries[i].card.id == id { + return a.entries[i].card, true + } + } + return nil, false +} + +// TestAProposalDecidedBeforeThisWindowArrivedIsDrawnAnsweredAndNotAsked is the +// owner's defect from the surface's side: approve a task, switch tab, come back, +// and be asked the same question again — every time, with the question taking +// the keyboard so the conversation behind it could not be scrolled either. +func TestAProposalDecidedBeforeThisWindowArrivedIsDrawnAnsweredAndNotAsked(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.proposeTask(decidedProposal(7, session.TaskAnswer{Approved: true})) + + if len(a.questions) != 0 { + t.Fatalf("the block is asking %d questions about a decision already made", len(a.questions)) + } + card, drawn := settledCard(a, 7) + if !drawn { + t.Fatal("the conversation kept no card for a proposal that was approved") + } + if !card.settled() { + t.Fatal("the card is still counting on a proposal that was answered") + } + if card.verdict != taskApprovedWord { + t.Fatalf("the card says %q, want %q", card.verdict, taskApprovedWord) + } + if card.answer != "start it" { + t.Fatalf("the card's answer reads %q, want the option's own label", card.answer) + } +} + +// TestADeclinedProposalDrawnOnALaterAttachSaysSo is the other answer, so the +// replay cannot draw every settled card as a yes. +func TestADeclinedProposalDrawnOnALaterAttachSaysSo(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.proposeTask(decidedProposal(8, session.TaskAnswer{})) + + card, drawn := settledCard(a, 8) + if !drawn { + t.Fatal("the conversation kept no card for a proposal that was declined") + } + if card.verdict != taskDeclinedWord { + t.Fatalf("the card says %q, want %q", card.verdict, taskDeclinedWord) + } + if len(a.questions) != 0 { + t.Fatalf("the block is asking %d questions about a decision already made", len(a.questions)) + } +} + +// TestAProposalNobodyHasDecidedIsStillAsked keeps the fix from being a deletion: +// an open card is exactly what a surface must be asked about. +func TestAProposalNobodyHasDecidedIsStillAsked(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + open := decidedProposal(9, session.TaskAnswer{}) + open.Task.Decided = nil + a.proposeTask(open) + + if len(a.questions) != 1 { + t.Fatalf("the block is asking %d questions about an open proposal, want 1", len(a.questions)) + } + if card, drawn := settledCard(a, 9); !drawn || card.settled() { + t.Fatalf("an open proposal's card is drawn as %+v, want one still waiting", card) + } +} From 7ccfdd12f49d167b426ee085261a29a78aaf2993 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 14 Sep 2026 06:36:09 -0400 Subject: [PATCH 3/3] docs: the change entry for #1036 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../unreleased/1036-task-question-reask.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/changes/unreleased/1036-task-question-reask.md diff --git a/docs/changes/unreleased/1036-task-question-reask.md b/docs/changes/unreleased/1036-task-question-reask.md new file mode 100644 index 0000000000..326e2d15dd --- /dev/null +++ b/docs/changes/unreleased/1036-task-question-reask.md @@ -0,0 +1,49 @@ +--- +kind: fixed +title: a task-start question you answered is not asked again when you come back to the tab +pr: 1036 +surface: [chat, engine] +invalidates: + - "A turn's backlog was replayed VERBATIM to whoever attached next + (session's [eventHub.attach]), cards included. A card is the one event in a + turn that is not a report of something that happened but a QUESTION about + something that has not, so a person who approved a task, looked at another + tab and came back was asked to approve it again — on every return, for as + long as that turn ran — and because a question takes the keyboard where it is + drawn (#919), the conversation underneath it could not be scrolled while it + stood. The replay now consults the answer: `eventHub.attach` is handed what + is still being asked ([Agent.stillAskedLocked], the banked words read under + a.mu at the moment of the attach) and leaves out a card whose question is no + longer one. The BACKLOG is unchanged and is still a faithful log of the turn; + what is filtered is the replay. `questionAsked` names the question a card + put in front of somebody, in the lanes' own keys, and it covers consent, a + task proposal, a standing card, a harness offer and a connect ask alike — so + a permission granted mid-turn is not re-asked on a re-attach either." + - "`TaskNotice.Withdrawn` was the ONLY outcome a proposal's card ever restated: + a proposal that was ANSWERED said nothing on the turn's stream, and what the + person's answer did lived only in the window that pressed the key. + `TaskNotice.Decided` is the other ending, sent by [taskWait.decided] the + moment anybody settles the proposal — the person, another window, or the + countdown — from the one place both roads pass through. A card that states + its own outcome is not asking anything, so the replay carries it in the open + card's place and a window arriving late draws the assignment with the answer + under it. The CLOCK's own wording (`approved · the clock`) belongs to the + window that ran the clock and is not restated; a replayed card says + `approved`, which is what is true afterwards." + - "tui3 spelled the settled proposal's verdict inside [app.taskAnswered] and + nowhere else. `taskVerdict` is that one reading of approved-and-redirect, and + both the window whose key answered and the window replaying a decision take + it; `taskAnswerWord` reads the answer's label off the proposal's own options + rather than spelling one a second time." +--- + +The two symptoms the owner met were one defect. A card that will not leave the +conversation is also a card that owns the viewport: the question was re-raised by +the replay, nothing would ever close it because the engine had already recorded +the answer, and every key aimed at the conversation went to it instead. + +Leaving the settled card out of the replay altogether was the first attempt and +was not enough — the surface reads a `propose_task` result with no card as a call +that was REFUSED ([app.refuseFormingCard]), so coming back said +`not started · the call was refused` over work that was running. The engine +states the outcome instead, and the replay has something true to carry.