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
6 changes: 5 additions & 1 deletion e2e/subagent-orchestration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
openE2ESession,
runUntilDone,
} from "./harness.js";
import { DIRECTOR_RESOLVABLE_ESCALATION } from "../testkit/escalation.js";

const RequestURL = type({ url: "string" });

Expand Down Expand Up @@ -69,7 +70,10 @@ describe("e2e — fleet orchestration", () => {
toolCalls: [
{
name: "ask_director",
args: { question: "which file should I edit?" },
args: {
question: "which file should I edit?",
escalation: DIRECTOR_RESOLVABLE_ESCALATION,
},
},
],
});
Expand Down
9 changes: 8 additions & 1 deletion e2e/subagent-permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { createSubAgentSessionStore } from "../src/subagent/session-store.js";
import { workerPermissionGate } from "../src/permission/reactor-authorize.js";
import { getProcessWorkerGrantStore } from "../src/permission/worker-grant.js";
import { gateAgentTools } from "../src/plugins/permission-plugin.js";
import { DIRECTOR_RESOLVABLE_ESCALATION } from "../testkit/escalation.js";

const report =
"## Summary\nFinished.\n## Findings\nAttempted write.\n## Blockers\nNone.\n## Paths\nprobe.txt";
Expand Down Expand Up @@ -928,7 +929,13 @@ test.serial(
};
harness.scenario.replyOnce("openai", {
toolCalls: [
{ name: "ask_director", args: { question: "which file?" } },
{
name: "ask_director",
args: {
question: "which file?",
escalation: DIRECTOR_RESOLVABLE_ESCALATION,
},
},
],
});
harness.scenario.replyOnce("openai", { text: report });
Expand Down
18 changes: 18 additions & 0 deletions src/permission/worker-grant-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ const shellCall = (id: string, command: string): ToolCall => ({
arguments: { command },
});

/** Valid assessed ask payload for the grant-flow fixture. */
const assessedAsk = {
policyVersion: "1",
classification: "operator_decision_required",
blockedOutcome: "cannot produce the authenticated verification result",
unavailableDirectorPath: "the director has no credential authority",
permittedAlternatives: [
{
attempted: "focused local test",
result: "requires the missing credential",
comparableConfidence: false,
},
],
minimumAddition: "allow an authenticated read-only snapshot",
declineConsequence: "implementation is complete but verification is blocked",
} as const;

function makeParentGate(cwd: string, approve: boolean) {
return createPermissionGate({
approvals: [],
Expand Down Expand Up @@ -195,6 +212,7 @@ describe("worker grant-request flow: deny → parent replay grant → one retry"
sessions.registerAsk(session.id, {
question: `blocked; quote request ${envelope.requestId}`,
questionId: "ask-1",
assessment: assessedAsk,
resolve: (answer) => {
resolved = answer;
},
Expand Down
91 changes: 90 additions & 1 deletion src/subagent/agent-fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ async function spawnParkedAsk(
askReply = params.askDirectorPort?.register({
question: "which file should I edit?",
questionId: "ask-1",
assessment: assessedAsk,
});
void askReply?.catch(() => undefined);
return gate.promise;
Expand Down Expand Up @@ -255,6 +256,31 @@ async function expectListShows(
expect(defined(parsed.agents[0]).status).not.toBe("awaiting_director");
}

/** Valid assessed ask payload for the escalation projection tests. */
const assessedAsk = {
policyVersion: "1",
classification: "operator_decision_required",
blockedOutcome: "cannot produce the authenticated verification result",
unavailableDirectorPath: "the director has no credential authority",
permittedAlternatives: [
{
attempted: "focused local test",
result: "requires the missing credential",
comparableConfidence: false,
},
],
minimumAddition: "allow an authenticated read-only snapshot",
declineConsequence: "implementation is complete but verification is blocked",
requestedMechanism: "broad web access",
minimumAuthority: "authenticated read-only snapshot",
verification: {
verificationOutcome: "integration verification",
rootCause: "missing test credential",
attemptedNarrowChecks: ["focused local test"],
reducedConfidence: "integration behavior remains unverified",
},
} as const;

function parkAsk(
sessions: ReturnType<typeof createSubAgentSessionStore>,
mailbox: ReturnType<typeof createFleetMailbox>,
Expand All @@ -267,6 +293,7 @@ function parkAsk(
sessions.registerAsk(id, {
question: "which file?",
questionId: "ask-1",
assessment: assessedAsk,
resolve: () => undefined,
reject: () => undefined,
}),
Expand Down Expand Up @@ -1752,6 +1779,62 @@ describe("list_agents", () => {
gate.resolve({ report: "done" });
});

test("wait and list JSON emit assessment and terminal_outcome with stable field names (SF1)", async () => {
const { gate, deps, list, id } = await spawnParkedAsk();
// list first: surfacing via wait would refuse a duplicate list (anti-poll).
const listed = await callFleetToolRaw(list, {});
expect(listed.isError).not.toBe(true);
const parsed = parseFleetJson(listed.content) as {
agents: {
agent_id: string;
status: string;
assessment?: Record<string, unknown>;
terminal_outcome?: Record<string, unknown>;
}[];
};
expect(defined(parsed.agents[0]).assessment).toMatchObject({
policyVersion: "1",
classification: "operator_decision_required",
minimumAuthority: "authenticated read-only snapshot",
});
const wait = waitTool(deps);
const waited = await callFleetTool(wait, {
targets: [id],
timeout_ms: 2000,
});
expect(waited.timed_out).toBe(false);
const waitRow = defined((waited.results as Record<string, unknown>[])[0]);
expect(waitRow.assessment).toMatchObject({
policyVersion: "1",
classification: "operator_decision_required",
minimumAuthority: "authenticated read-only snapshot",
});
// Resolve through the public store API; the terminal outcome must then
// surface in list JSON.
expect(
deps.sessions.resolveEscalationAsk(id, {
kind: "declined",
answer: "No credential grant.",
questionId: "ask-1",
}),
).toBe(true);
const after = await callFleetToolRaw(list, {});
expect(after.isError).not.toBe(true);
const parsedAfter = parseFleetJson(after.content) as {
agents: {
agent_id: string;
status: string;
assessment?: Record<string, unknown>;
terminal_outcome?: Record<string, unknown>;
}[];
};
expect(defined(parsedAfter.agents[0]).terminal_outcome).toMatchObject({
kind: "verification_blocked",
resolution: "declined",
});
gate.resolve({ report: "done" });
});

test("errors after wait_agents surfaces awaiting_director with a question", async () => {
const { gate, deps, list, id } = await spawnParkedAsk();
const wait = waitTool(deps);
Expand Down Expand Up @@ -1839,6 +1922,7 @@ describe("list_agents", () => {
.register({
question: "which test should I add?",
questionId: "ask-2",
assessment: assessedAsk,
})
.catch(() => undefined);
await waitUntilAwaitingDirector(deps.fleetRecords, deps.sessions, id);
Expand Down Expand Up @@ -2162,6 +2246,7 @@ describe("ask_director wait handshake", () => {
?.register({
question: "which file?",
questionId: "ask-1",
assessment: assessedAsk,
})
.catch(() => {
// Session settlement rejects an unanswered ask; this test does not await it.
Expand Down Expand Up @@ -2241,7 +2326,11 @@ describe("ask_director wait handshake", () => {
process.on("unhandledRejection", onUnhandled);
try {
expect(() =>
defined(port).register({ question: "late?", questionId: "ask-1" }),
defined(port).register({
question: "late?",
questionId: "ask-1",
assessment: assessedAsk,
}),
).toThrow("could not register");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unhandled).toEqual([]);
Expand Down
46 changes: 44 additions & 2 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ import {
projectWaitStatus,
type WaitJSONStatus,
} from "./lifecycle.js";
import type {
EscalationAssessment,
TerminalOutcome,
} from "./escalation-policy.js";
import { getProcessAdmissionQueue, type AdmissionQueue } from "./admission.js";
import type {
NestedDispatchDeps,
Expand Down Expand Up @@ -141,6 +145,8 @@ interface FleetRecord {
hint?: string;
question?: string;
questionId?: string;
assessment?: EscalationAssessment;
terminalOutcome?: TerminalOutcome;
description?: string;
}

Expand Down Expand Up @@ -467,7 +473,16 @@ class FleetMailbox {
? { recoverableFailure: true }
: {}),
...(ask !== undefined
? { question: ask.question, questionId: ask.questionId }
? {
question: ask.question,
questionId: ask.questionId,
...(ask.assessment !== undefined
? { assessment: ask.assessment }
: {}),
}
: {}),
...(session?.terminalOutcome !== undefined
? { terminalOutcome: session.terminalOutcome }
: {}),
...(status === "awaiting_director" && session !== undefined
? { description: session.description }
Expand Down Expand Up @@ -1548,7 +1563,12 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
: {}),
persist: deps.persist !== false,
askDirectorPort: {
register: ({ question, questionId, grantRequestId }) => {
register: ({
question,
questionId,
assessment,
grantRequestId,
}) => {
const hold: {
resolve?: (answer: string) => void;
reject?: (reason: unknown) => void;
Expand All @@ -1568,6 +1588,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
const ok = deps.sessions.registerAsk(session.id, {
question,
questionId,
assessment,
...(grantRequestId !== undefined ? { grantRequestId } : {}),
resolve: hold.resolve,
reject: hold.reject,
Expand All @@ -1585,6 +1606,9 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
cancel: (reason) => {
deps.sessions.cancelAsk(session.id, reason);
},
recordRoutineEvaluation: (assessment) => {
deps.sessions.recordRoutineEvaluation(session.id, assessment);
},
},
onAgentReady: ({ close, interrupt, followup, deliver }) => {
deps.sessions.registerClose(session.id, async (deadlineMs) => {
Expand Down Expand Up @@ -1862,6 +1886,12 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
...(record.questionId !== undefined
? { question_id: record.questionId }
: {}),
...(record.assessment !== undefined
? { assessment: record.assessment }
: {}),
...(record.terminalOutcome !== undefined
? { terminal_outcome: record.terminalOutcome }
: {}),
...(record.description !== undefined
? { description: record.description }
: {}),
Expand Down Expand Up @@ -1894,6 +1924,12 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
...(record.questionId !== undefined
? { question_id: record.questionId }
: {}),
...(record.assessment !== undefined
? { assessment: record.assessment }
: {}),
...(record.terminalOutcome !== undefined
? { terminal_outcome: record.terminalOutcome }
: {}),
};
});

Expand Down Expand Up @@ -2003,6 +2039,12 @@ export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool {
record.questionId !== undefined
? { question_id: record.questionId }
: {}),
...(record?.assessment !== undefined
? { assessment: record.assessment }
: {}),
...(record?.terminalOutcome !== undefined
? { terminal_outcome: record.terminalOutcome }
: {}),
};
});
if (parked.length > 0) {
Expand Down
Loading
Loading