fix(negotiations): the listing must say the park, from the same record the question came from - #1472
Merged
Merged
Conversation
…d the question came from A client asked "do we have a question?" while a negotiation had sat parked `input_required` on her side for two hours with the open question "Timing: This week". Every #1470 surface was correct — the precedence gate found the question, the prompt's open-questions section named it at position 1. Then the model called `list_negotiations`, which renders lifecycle from OPPORTUNITY STATUS, where the pairing legitimately reads `negotiating`, and which said nothing about the park at all. Holding a static context line saying one thing and a just-executed tool saying another, it went with the tool: "there are currently no open questions… nothing for you to decide." The listing was the last surface deriving "what is happening" from a source other than the shared resolver, so the tool and the context could disagree — and the tool wins the model's trust every time. It now carries a `park` on any negotiation holding one. The question's number and label come from `NegotiationListingParkHost`, whose api-side host resolves them through `readOpenQuestionsForIntent` — the same call the prompt section and `answer_pending_question` make — so the number the client is shown is the number that routes their answer. Whose side a park is on comes from the canonical predicate: `classifyParkedNegotiation` is split into `classifyInflightPark`/`classifyPostStallPark`, pure over the task and messages the listing already holds, so classification costs no extra query and cannot drift. A park on the counterparty is narrated, never quoted. `lifecycleLabel` states the park, superseding the status label — the persona is told to narrate from it, and "the agents are still negotiating" is the sentence that became a false close-out. `opportunityStatus` still reports the truth. A negotiation with no park renders byte-for-byte as before, pinned by a fixture. Claude-Session: https://claude.ai/code/session_01D46BEDXrtVKWUy9LNJ4JNR
yanekyuk
added a commit
that referenced
this pull request
Aug 20, 2026
Since #1472 an MCP client can SEE a park — "open question 3, 'Timing'" — and could do nothing about it. This closes the read-and-act story: get_negotiation says the park (same predicate, same shared record as the listing), answer_pending_question routes the answer over the existing #1466 host, reject/accept_opportunity give session-authenticated owners the #1471 verdict lane, the status filter gains input_required, the activity summary counts parked negotiations instead of the retired questions table, and the guidance/tool descriptions say all of it — including what external turns are NOT protected by. Claude-Session: https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The incident
Sandbox, 2026-08-20 22:04 UTC, negotiator DM for intent
34fa30bc-…(Hye-jin Park).The client asked "Do we have a question?" while a negotiation sat parked
input_requiredon her side — taskaefff73b-…, open question "Timing: This week", parked for two hours.Every #1470 surface was correct. The precedence gate found the question (logged
declined— only reachable after finding one). The system prompt's open-questions section named it: position 1, "Timing: This week", recovered from the buried delivered message.Then the model called
list_negotiations. That tool renders pairings from OPPORTUNITY STATUS — where this pairing legitimately readsnegotiating— and never surfaced that the negotiation was parked waiting on the client with a named open question. Holding a static context line saying one thing and a just-executed tool result saying another, the model went with the tool, as any model will:Both clauses false at the task level; both faithful to the tool's rendering.
Root cause: the listing was the last surface still deriving "what's happening" from a source other than the shared resolver, so the tool and the context could disagree — and the tool wins the model's trust every time.
The fix
The listing says the park, from the same source of truth.
A pairing parked on the VIEWER renders as parked-waiting-on-you and NAMES the open question with its number. The number comes from
NegotiationListingParkHost, whose api-side host (services/api/src/lib/question/negotiation-listing-park.host.ts) resolves it throughreadOpenQuestionsForIntent— the same call the open-questions prompt section andanswer_pending_questionmake. The listing enumerates nothing of its own, so the number the client is shown is the number that routes their answer. A question'salsoUnblocksrefs carry that same number, since one answer resumes them all.A pairing parked on the COUNTERPARTY renders honestly too — waiting on their side, with no question content. That question is not this client's to read.
Statuses with no park keep today's rendering byte-for-byte, pinned by a fixture spec.
Parked-ness is never re-derived from opportunity status or a second predicate.
classifyParkedNegotiationis split intoclassifyInflightPark/classifyPostStallPark— pure over the task and messages a caller already holds — and the async function becomes the reading half around them, keeping its lazy messages read. The listing calls them with material it already loaded: no extra query, no possible drift from the predicate answer routing uses.lifecycleLabelstates the park, superseding the status label. The persona is told to takelifecycleLabelas its user-facing wording, and "the agents are still negotiating" is exactly the sentence that became a false close-out.opportunityStatusstill reports the true status; what it may no longer do is supply the sentence.connectionStategainsparked_awaiting_your_answer/parked_awaiting_counterparty.Prompt: the tool description and the negotiator persona both state that the listing's park annotations and the open-questions context section come from the same record — so the model has no basis to rank one over the other — and that
negotiatingalone never means "nothing is waiting on you".The host is optional. Without it the listing still says whether a pairing is parked and on whose side; what it loses is the question's number.
Live verification (mechanical half)
list_negotiationsrendered against the sandbox for the incident's exact(user 6c17f313-…, intent 34fa30bc-…). The Beatrice Hall pairing (72531450-…) now carries the parked line:{ "id": "aefff73b-d61f-4864-bfd8-859a8758ddcf", "counterpartyId": "72531450-4224-5612-b192-670629ab98af", "status": "input_required", "latestAction": "ask_user", "park": { "waitingOn": "you", "kind": "mid_flight", "question": 1, "questionLabel": "Timing: This week", "label": "PARKED — waiting on YOUR client’s answer to open question 1, “Timing: This week”. The agents are NOT exchanging turns and will not until the client answers; this is something for them to decide." }, "lifecycle": { "opportunityStatus": "negotiating", "connectionState": "parked_awaiting_your_answer", "lifecycleLabel": "PARKED — waiting on YOUR client’s answer to open question 1, “Timing: This week”. …" } }opportunityStatusis still truthfullynegotiating. It is no longer the sentence.The human half — asking the agent "do we have a question?" again — is yours.
Tests
services/api/src/lib/question/tests/negotiation-listing-park.host.spec.ts— the anti-divergence spec (fix(negotiations): a question is open because its negotiation is parked, not because its message is newest #1470's pattern): one fixture drives bothreadListingOpenQuestionsandreadOpenQuestionsForIntent, and the listing's numbers are asserted equal to the enumeration's. Plus the incident's buried question at number 1,alsoUnblocksexpansion, empty parked set, and degradation to no-annotations on a failed read.packages/protocol/src/negotiations/tests/negotiation.tools.park.spec.ts— viewer park named with the record's number (a fixture where the record says 3 and a self-counting listing would say 1); the incident as a spec (negotiating+input_requiredon viewer → cannot render "negotiating" without the park); counterparty park narrated without content, asserted absent from the serialized result; post-stall park on the viewer; park-without-host; and the no-park fixture pin, field-for-field.src/negotiations/tests.Pre-existing failures, verified unrelated:
negotiator-discovery-query.spec.ts(2) — live-LLM eval flake; passes in isolation (4/4).chat.graph/chat.suggester/chat.summarizer(11) — whole-dir-run pollution; all pass in isolation.tests/mcp.spec.ts(4, owner-approval/IND-593) — baselined by revertingmcp.controller.tstoHEADand re-running: identical 66 pass / 4 fail.Adjacent gap, not fixed here
status: 'active'maps to db stateworking, so a parked (input_required) negotiation is excluded from a filtered listing entirely — same family of "the listing says nothing is happening", but changing the filter's semantics is outside this diff. Flagging it rather than folding it in.Boundaries
packages/protocol(listing rendering/guidance + the park predicate split + the new host interface), the api-side listing host, and its composition-root wiring. No changes toopen-question-message.ts. No flags. Protocol patch bump 23.5.0 → 23.5.1 + changelog + lockfile sync (bun scripts/sync-lockfile-versions.ts).🤖 Generated with Claude Code
https://claude.ai/code/session_01D46BEDXrtVKWUy9LNJ4JNR