Skip to content
10 changes: 10 additions & 0 deletions commands/__tests__/gate-ask-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ describe("buildGateAskPayload", () => {
expect(p.context).toBe("why");
expect(p.kind).toBe("plan");
});
test("option descriptions and per-question context ride --questions through untouched", () => {
const q = JSON.stringify([{
id: "q1", label: "go?", multi: false, context: "why this one",
options: [{ value: "yes", label: "yes", description: "ship it", recommended: true }, "no"],
}]);
expect(buildGateAskPayload(["--questions", q], noEnv).questions).toEqual([{
id: "q1", label: "go?", multi: false, context: "why this one",
options: [{ value: "yes", label: "yes", description: "ship it", recommended: true }, "no"],
}]);
});
test("empty env vars are treated as unset", () => {
const env = { CLAUDE_CODE_SESSION_ID: "", HERDR_PANE_ID: "" } as NodeJS.ProcessEnv;
const p = buildGateAskPayload(["--questions", Q], env);
Expand Down
8 changes: 8 additions & 0 deletions commands/__tests__/gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ describe("withGateTokens", () => {
expect(a).toMatchObject({ presentation: "form", owner: "herd:h-1" });
expect(b).toMatchObject({ presentation: "wait", owner: "human" });
});
test("questions echo verbatim: option descriptions and per-question context survive into list --json", () => {
const questions = [{
id: "q1", label: "go?", multi: false, context: "why this one",
options: [{ value: "yes", label: "Yes", description: "ship it" }, { value: "no", label: "No" }],
}];
const [row] = withGateTokens([fakeRow({ questions })]);
expect(row!.questions).toEqual(questions);
});
});

// ─── subscriptions ───────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion commands/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export async function gateAsk(args: string[]): Promise<void> {
if (!res.ok || res.data === undefined) askFail(res.error ?? "ask failed");
const data = res.data;
if (data.contextOmitted) {
console.error(`rt gate: context omitted: it exceeded the ${CONTEXT_CAP_BYTES}-byte cap, so this gate carries none; shorten it and re-ask`);
console.error(`rt gate: context omitted: gate context plus question contexts exceeded the shared ${CONTEXT_CAP_BYTES}-byte budget; question contexts were dropped, and the gate context too if it was over on its own; shorten and re-ask`);
}
console.log(JSON.stringify(gateAskOutput(data)));
}
Expand Down
48 changes: 48 additions & 0 deletions e2e/tests/gate-ask-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,54 @@ describe("rt gate ask CLI e2e", () => {
});
}, 30_000);

// RT-184: the per-option explanations and per-question context the pane's
// form shows must reach the gate row, or the board renders bare labels.
test("option descriptions and per-question context round-trip ask -> list --json, normalized like labels are", async () => {
const questions = JSON.stringify([{
id: "q", label: "Pick", multi: false, context: "what this one turns on",
options: [{ value: "a", label: "fix", description: "patch the null check", recommended: true }, "b"],
}]);
const asked = await finished(runRt(
["gate", "ask", "--questions", questions, "--subject", "mr:e2e-structured", "--context", "the diff under decision, quoted"],
home,
{ HERDR_PANE_ID: "pane-8", CLAUDE_CODE_SESSION_ID: "sess-8-structured" },
));
expect(asked.exitCode).toBe(0);
const { id, contextOmitted } = JSON.parse(asked.stdout) as { id: string; contextOmitted?: true };
expect(contextOmitted).toBeUndefined();

const listed = await finished(runRt(["gate", "list", "--json", "--subject-prefix", "mr:e2e-structured"], home));
expect(listed.exitCode).toBe(0);
const { gates } = JSON.parse(listed.stdout) as { gates: Array<{ id: string; context: string | null; questions: unknown }> };
const row = gates.find((g) => g.id === id)!;
expect(row.context).toBe("the diff under decision, quoted");
expect(row.questions).toEqual([{
id: "q", label: "Pick", multi: false, context: "what this one turns on",
options: [{ value: "a", label: "Fix (Recommended)", description: "patch the null check" }, { value: "b", label: "B" }],
}]);
}, 30_000);

test("question contexts over the shared budget are dropped, the gate context kept, and the envelope says contextOmitted", async () => {
const questions = JSON.stringify([
{ id: "q", label: "Pick", multi: false, options: ["a", "b"], context: "x".repeat(4096) },
{ id: "m", label: "Pick many", multi: true, options: ["a", "b"], context: "y".repeat(4097) },
]);
const asked = await finished(runRt(
["gate", "ask", "--questions", questions, "--subject", "mr:e2e-budget", "--context", "kept"],
home,
{ HERDR_PANE_ID: "pane-9", CLAUDE_CODE_SESSION_ID: "sess-9-budget" },
));
expect(asked.exitCode).toBe(0);
const parsed = JSON.parse(asked.stdout) as { id: string; contextOmitted?: true };
expect(parsed.contextOmitted).toBe(true);

const listed = await finished(runRt(["gate", "list", "--json", "--subject-prefix", "mr:e2e-budget"], home));
const { gates } = JSON.parse(listed.stdout) as { gates: Array<{ id: string; context: string | null; questions: Array<Record<string, unknown>> }> };
const row = gates.find((g) => g.id === parsed.id)!;
expect(row.context).toBe("kept");
expect(row.questions.map((q) => "context" in q)).toEqual([false, false]);
}, 30_000);

test("missing --questions: ok:false usage refusal, exit 1, no daemon contact", async () => {
const res = await finished(runRt(
["gate", "ask"],
Expand Down
93 changes: 93 additions & 0 deletions lib/daemon/__tests__/gate-ask-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,3 +453,96 @@ describe("gate:ask context enforcement", () => {
expect(store.get(res.data.id)!.context).toBeNull();
});
});

describe("gate:ask structured question context (RT-184)", () => {
const CTX = "the plan section under decision, quoted verbatim";
const withContexts = (a: number, b: number): GateQuestion[] => [
{ id: "q1", label: "go?", multi: false, options: ["yes", "no"], context: "x".repeat(a) },
{ id: "q2", label: "how?", multi: false, options: ["fast", "slow"], context: "y".repeat(b) },
];

test("option descriptions and per-question context reach the stored row", async () => {
const { handlers, store } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({
subject: "mr:x", context: CTX,
questions: [{
id: "q1", label: "go?", multi: false, context: "per-question material",
options: [{ value: "yes", label: "yes", description: "ship it" }, "no"],
}],
});
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.contextOmitted).toBeUndefined();
const row = store.get(res.data.id)!;
expect(row.context).toBe(CTX);
expect(row.questions).toEqual([{
id: "q1", label: "go?", multi: false, context: "per-question material",
options: [{ value: "yes", label: "Yes", description: "ship it" }, { value: "no", label: "No" }],
}]);
});

test("under the shared 8192-byte budget nothing is dropped", async () => {
const { handlers, store } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({ subject: "mr:x", context: "g".repeat(4096), questions: withContexts(2048, 2048) });
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.contextOmitted).toBeUndefined();
const row = store.get(res.data.id)!;
expect(row.context).toBe("g".repeat(4096));
expect(row.questions[0]!.context).toBe("x".repeat(2048));
expect(row.questions[1]!.context).toBe("y".repeat(2048));
});

test("over the shared budget, question contexts are dropped first and the gate context kept, reported as contextOmitted", async () => {
const { handlers, store } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({ subject: "mr:x", context: "g".repeat(4096), questions: withContexts(2048, 2049) });
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.contextOmitted).toBe(true);
const row = store.get(res.data.id)!;
expect(row.context).toBe("g".repeat(4096));
expect(Object.keys(row.questions[0]!)).not.toContain("context");
expect(Object.keys(row.questions[1]!)).not.toContain("context");
});

test("a gate context that is over budget on its own drops question contexts too", async () => {
const { handlers, store } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({ subject: "mr:x", context: "g".repeat(9000), questions: withContexts(10, 10) });
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.contextOmitted).toBe(true);
const row = store.get(res.data.id)!;
expect(row.context).toBeNull();
expect(Object.keys(row.questions[0]!)).not.toContain("context");
});

test("question contexts alone over budget are dropped and reported, with no gate context to keep", async () => {
const { handlers, store } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({ subject: "mr:x", kind: "milestone", questions: withContexts(4096, 4097) });
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.contextOmitted).toBe(true);
const row = store.get(res.data.id)!;
expect(Object.keys(row.questions[0]!)).not.toContain("context");
});

test("a human-owned, non-exempt gate with no gate context is still refused when only question contexts overflow the budget", async () => {
const { handlers } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({ subject: "mr:x", questions: withContexts(4096, 4097) });
expect(res.ok).toBe(false);
if (res.ok) return;
expect(res.error).toContain("context");
expect(res.error).toContain("decide from alone");
});

test("an oversized option description is a hard reject, not a drop: the caller authored it", async () => {
const { handlers } = harness({ resolveSubject: () => ({ ok: true, subject: "mr:x" }) });
const res = await handlers["gate:ask"]({
subject: "mr:x", context: CTX,
questions: [{ id: "q1", label: "go?", multi: false, options: [{ value: "yes", label: "Yes", description: "d".repeat(1025) }, "no"] }],
});
expect(res.ok).toBe(false);
if (res.ok) return;
expect(res.error).toContain("1024 bytes");
});
});
87 changes: 87 additions & 0 deletions lib/daemon/__tests__/gates-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,93 @@ describe("gate:open W4 fields", () => {
});
});

describe("gate:open structured question context (RT-184)", () => {
const described = (): GateQuestion[] => [{
id: "q", label: "Pick", multi: false, context: "what this choice turns on",
options: [{ value: "a", label: "fix", description: "patch the null check", recommended: true }, "b"],
}];

test("stores option descriptions and per-question context verbatim on the row AND the opened event payload", async () => {
const { handlers, store, emitted } = harness();
const r = await handlers["gate:open"]({ subject: "run:r1", kind: "clarify", questions: described(), context: "gate-level" });
expect(r.ok).toBe(true);
const id = (r as { data: { id: string } }).data.id;
const expected = [{
id: "q", label: "Pick", multi: false, context: "what this choice turns on",
options: [{ value: "a", label: "Fix (Recommended)", description: "patch the null check" }, { value: "b", label: "B" }],
}];
expect(store.get(id)!.questions).toEqual(expected);
const opened = emitted.find((e) => e.topic === `gate/opened/${id}`)!;
expect((opened.payload as { questions: unknown }).questions).toEqual(expected);
});

test("a trim-empty question context is absent from the stored row", async () => {
const { handlers, store } = harness();
const r = await handlers["gate:open"]({
subject: "run:r1", kind: "clarify",
questions: [{ id: "q", label: "Pick", multi: false, options: ["a", "b"], context: " \n " }],
});
expect(r.ok).toBe(true);
const row = store.get((r as { data: { id: string } }).data.id)!;
expect(Object.keys(row.questions[0]!)).not.toContain("context");
});

test("rejects a non-string question context as invalid questions", async () => {
const { handlers } = harness();
const r = await handlers["gate:open"]({
subject: "run:r1", kind: "clarify",
questions: [{ id: "q", label: "Pick", multi: false, options: ["a", "b"], context: 7 }] as never,
});
expect(r.ok).toBe(false);
expect((r as { error: string }).error).toBe("invalid questions");
});

test("rejects a non-string option description as invalid questions", async () => {
const { handlers } = harness();
const r = await handlers["gate:open"]({
subject: "run:r1", kind: "clarify",
questions: [{ id: "q", label: "Pick", multi: false, options: [{ value: "a", label: "A", description: 7 }] }] as never,
});
expect(r.ok).toBe(false);
expect((r as { error: string }).error).toBe("invalid questions");
});

test("rejects an option description over 1024 bytes, naming the cap; accepts one at the cap", async () => {
const { handlers } = harness();
const withDescription = (description: string): GateQuestion[] =>
[{ id: "q", label: "Pick", multi: false, options: [{ value: "a", label: "A", description }, "b"] }];
const over = await handlers["gate:open"]({ subject: "run:r1", kind: "clarify", questions: withDescription("x".repeat(1025)) });
expect(over.ok).toBe(false);
expect((over as { error: string }).error).toContain("1024 bytes");
expect((over as { error: string }).error).toContain('"q"');
const at = await handlers["gate:open"]({ subject: "run:r1", kind: "clarify", questions: withDescription("x".repeat(1024)) });
expect(at.ok).toBe(true);
});

test("gate context and question contexts share one 8192-byte budget: over it is rejected naming the cap, at it is accepted", async () => {
const { handlers } = harness();
const withContexts = (a: number, b: number): GateQuestion[] => [
{ id: "q", label: "Pick", multi: false, options: ["a", "b"], context: "x".repeat(a) },
{ id: "m", label: "Pick many", multi: true, options: ["a", "b"], context: "x".repeat(b) },
];
const over = await handlers["gate:open"]({ subject: "run:r1", kind: "clarify", questions: withContexts(2048, 2049), context: "x".repeat(4096) });
expect(over.ok).toBe(false);
expect((over as { error: string }).error).toContain("8192 bytes");
const at = await handlers["gate:open"]({ subject: "run:r1", kind: "clarify", questions: withContexts(2048, 2048), context: "x".repeat(4096) });
expect(at.ok).toBe(true);
});

test("question contexts alone can exceed the budget: no gate context, 8193 bytes across questions is rejected", async () => {
const { handlers } = harness();
const r = await handlers["gate:open"]({
subject: "run:r1", kind: "clarify",
questions: [{ id: "q", label: "Pick", multi: false, options: ["a", "b"], context: "x".repeat(8193) }],
});
expect(r.ok).toBe(false);
expect((r as { error: string }).error).toContain("8192 bytes");
});
});

describe("gate:open owner derivation", () => {
test("gate:open derives herd owner from the run's spawner", async () => {
const { handlers, store } = harness({ runSpawnedBy: () => "herd:h-9" });
Expand Down
Loading
Loading