Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions plugins/doable-code-context/scripts/doable-code-context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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}`);
}

Expand Down
30 changes: 30 additions & 0 deletions tests/doable-code-context-helper.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading