From 359217e6cf04935baed69b433363c2a2b36c011b Mon Sep 17 00:00:00 2001 From: alphali <5236230+alphali@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:04:33 +0800 Subject: [PATCH] fix(code-context): follow the server's watch decision and gate the journey map Two places where the helper trusted itself or the prose over the server. The helper recomputed Next action locally, and for a creating or consumed pre-create Round that rule says "stop". A pre-create code can now hand over to its TRD's follow-up Rounds, and the server answers "wait" to say so, but the helper never read it: the agent still stopped at create. It now takes the server's next_action when the response carries one and falls back to the local rule only when it does not, so an older server behaves exactly as before. The helper sent `journeys` whenever the agent wrote them; the only condition was a line of Skill prose. A Doable backend that predates the field rejects any submission carrying it. record-round now keeps whether the Round asked for a journey map, and the submission drops declarations the Round did not request, reporting how many it omitted. Co-Authored-By: Claude Opus 5 --- .../scripts/doable-code-context.mjs | 30 +++++++++++++++---- tests/doable-code-context-helper.test.mjs | 30 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/plugins/doable-code-context/scripts/doable-code-context.mjs b/plugins/doable-code-context/scripts/doable-code-context.mjs index ef702a9..9df8370 100644 --- a/plugins/doable-code-context/scripts/doable-code-context.mjs +++ b/plugins/doable-code-context/scripts/doable-code-context.mjs @@ -927,7 +927,15 @@ function normalizeRound(data, state, requestedCode) { assert(baseCount <= 1, "follow-up round contains multiple base feature context requests"); } } - const action = watchAction(roundUse, status, questions); + // The server decides whether a connection continues: only it can see the TRD a + // pre-create Round was consumed into and the follow-up Rounds that code now reaches. + // Recomputing that here drifted from the server once already, so its answer wins. + // The local rule is the fallback for a server that sends none. + const serverAction = round.next_action ?? round.nextAction; + const action = ["answer", "wait", "stop"].includes(serverAction) + ? serverAction + : watchAction(roundUse, status, questions); + const journeyMap = round.journey_map ?? round.journeyMap; return { id, code, @@ -938,6 +946,8 @@ function normalizeRound(data, state, requestedCode) { testSuitePublicId, status, action, + // Carried into the frozen snapshot so the submission can be gated on it. + journeyMapRequested: journeyMap?.requested === true, featureScope, questions, establishedContext, @@ -1406,10 +1416,17 @@ function buildSubmission(statePath, candidatePath) { ]; validateFindingJourneys(allFindings); const conflicts = normalizeConflicts(candidate.conflicts || [], "conflicts", privacyState, allFindings); - const journeys = normalizeJourneys(candidate.journeys || [], "journeys", privacyState, allFindings); - const journeyMapSkipReason = candidate.journeyMapSkipReason + // Send a journey map only where this Round asked for one. A Doable backend that + // predates the field rejects any submission carrying it, and a gap-only follow-up + // never requests one, so the helper enforces this rather than trusting the prose. + const journeyMapRequested = frozenRound.journeyMapRequested === true; + const journeys = journeyMapRequested + ? normalizeJourneys(candidate.journeys || [], "journeys", privacyState, allFindings) + : []; + const journeyMapSkipReason = journeyMapRequested && candidate.journeyMapSkipReason ? string(candidate.journeyMapSkipReason, "journeyMapSkipReason", { max: 500 }) : undefined; + const omittedJourneyCount = journeyMapRequested ? 0 : (candidate.journeys || []).length; const payload = { round_revision: roundRevision, workspace_id: state.workspace.serverId, @@ -1421,18 +1438,21 @@ function buildSubmission(statePath, candidatePath) { ...(journeyMapSkipReason ? { journey_map_skip_reason: journeyMapSkipReason } : {}), }; assertRemotePayloadSafe(payload, privacyState); - return { state, frozenRound, payload, payloadDigest: sha256(stableJson(payload)) }; + return { state, frozenRound, payload, payloadDigest: sha256(stableJson(payload)), omittedJourneyCount }; } function validateSubmission(options) { const statePath = resolve(options.state || ".doable/workspace-private.json"); const candidatePath = resolve(requiredOption(options, "candidate")); - const { frozenRound, payload, payloadDigest } = buildSubmission(statePath, candidatePath); + const { frozenRound, payload, payloadDigest, omittedJourneyCount } = buildSubmission(statePath, candidatePath); const answered = payload.answers.filter((answer) => answer.status === "answered").length; const skipped = payload.answers.length - answered; console.log(`Round: ${frozenRound.code} revision ${frozenRound.revision}`); console.log(`Answers valid: ${answered} answered, ${skipped} skipped`); console.log(`Nonblocking observations: ${payload.agent_observations.length}`); + if (omittedJourneyCount > 0) { + console.log(`Journey map not requested by this Round; omitted ${omittedJourneyCount} declaration(s)`); + } console.log(`Safe payload digest: ${payloadDigest}`); } diff --git a/tests/doable-code-context-helper.test.mjs b/tests/doable-code-context-helper.test.mjs index 792bdf7..8ff03de 100644 --- a/tests/doable-code-context-helper.test.mjs +++ b/tests/doable-code-context-helper.test.mjs @@ -182,6 +182,7 @@ test("connected helper preserves the local/private boundary and retries idempote revision: 1, status: "open_for_agent", feature_scope: "Staff promotion creation", + journey_map: { requested: true, how: "", may_skip: "" }, questions: [ { id: "question-save-label", @@ -391,6 +392,22 @@ test("connected helper preserves the local/private boundary and retries idempote /journeys\[0\]\.goal exposes local repository provenance/i, ); + // A Round that did not ask for a map must never receive one: a backend that predates + // the field rejects the whole submission. Re-record the same Round without the request. + const unrequestedRoundPath = join(testRoot, "mcp-round-unrequested-response.json"); + writeFileSync( + unrequestedRoundPath, + JSON.stringify({ ...JSON.parse(readFileSync(roundResponsePath, "utf8")), journey_map: undefined }), + ); + await runHelper(["record-round", "--code", "DQ-7F3K", "--response", unrequestedRoundPath, "--state", statePath], environment); + writeFileSync(submissionPath, `${JSON.stringify(declaredJourneys, null, 2)}\n`); + const unrequestedOutput = await runHelper( + ["validate-submission", "--state", statePath, "--candidate", submissionPath], + environment, + ); + assert.match(unrequestedOutput, /Journey map not requested by this Round; omitted 1 declaration/); + await runHelper(["record-round", "--code", "DQ-7F3K", "--response", roundResponsePath, "--state", statePath], environment); + writeFileSync(submissionPath, `${JSON.stringify(submission, null, 2)}\n`); const invalidStatus = structuredClone(submission); @@ -829,6 +846,19 @@ test("agent-origin helper records the exact MCP round and finalize result", asyn ); assert.match(creatingOutput, /Next action: stop/); + // Without next_action the local rule still stops a pre-create Round (an older server). + // With one, the server wins: it can see the follow-up Rounds this code now reaches. + const handedOverResponsePath = join(testRoot, "mcp-round-handed-over-response.json"); + writeFileSync( + handedOverResponsePath, + JSON.stringify({ ...JSON.parse(readFileSync(creatingResponsePath, "utf8")), status: "consumed", next_action: "wait" }), + ); + const handedOverOutput = await runHelper( + ["record-round", "--code", "DQ-AGENT1", "--response", handedOverResponsePath, "--state", statePath], + environment, + ); + assert.match(handedOverOutput, /Next action: wait/); + const finalizeResponsePath = join(testRoot, "mcp-finalize-response.json"); writeFileSync( finalizeResponsePath,