Skip to content

fix(negotiations): the listing must say the park, from the same record the question came from - #1472

Merged
yanekyuk merged 1 commit into
devfrom
fix/listing-says-parked
Aug 20, 2026
Merged

fix(negotiations): the listing must say the park, from the same record the question came from#1472
yanekyuk merged 1 commit into
devfrom
fix/listing-says-parked

Conversation

@yanekyuk

Copy link
Copy Markdown
Contributor

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_required on her side — task aefff73b-…, 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 reads negotiating — 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:

"there are currently no open questions… I am still negotiating with Beatrice Hall's agent… nothing for you to decide."

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.

  1. 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 through readOpenQuestionsForIntent — the same call the open-questions prompt section and answer_pending_question make. The listing enumerates nothing of its own, so the number the client is shown is the number that routes their answer. A question's alsoUnblocks refs carry that same number, since one answer resumes them all.

  2. 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.

  3. Statuses with no park keep today's rendering byte-for-byte, pinned by a fixture spec.

  4. Parked-ness is never re-derived from opportunity status or a second predicate. classifyParkedNegotiation is split into classifyInflightPark / 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.

lifecycleLabel states the park, superseding the status label. The persona is told to take lifecycleLabel as its user-facing wording, and "the agents are still negotiating" is exactly the sentence that became a false close-out. opportunityStatus still reports the true status; what it may no longer do is supply the sentence. connectionState gains parked_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 negotiating alone 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_negotiations rendered 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”. …"
  }
}

opportunityStatus is still truthfully negotiating. It is no longer the sentence.

The human half — asking the agent "do we have a question?" again — is yours.

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 reverting mcp.controller.ts to HEAD and re-running: identical 66 pass / 4 fail.

Adjacent gap, not fixed here

status: 'active' maps to db state working, 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 to open-question-message.ts. No flags. Protocol patch bump 23.5.0 → 23.5.1 + changelog + lockfile sync (bun scripts/sync-lockfile-versions.ts).

Note on the handoff's file pointer: list_negotiations lives in packages/protocol/src/negotiations/negotiation.tools.ts (registered into the negotiator persona's toolset via the allowlist), not under src/chat/. The rendering change is there; the persona guidance line is in src/chat/negotiator.prompt.ts as specified.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D46BEDXrtVKWUy9LNJ4JNR

…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
yanekyuk merged commit 1204075 into dev Aug 20, 2026
7 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant