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,