Skip to content
Draft
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: 3 additions & 3 deletions packages/compass-agent/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2862,7 +2862,7 @@ describe("main wires the mounted agent-config into createAgentSession", () => {
...createForgeTools(new ForgeBroker(fakeTransport)),
...createBoardTools(new BoardBroker(fakeTransport)),
];
expect(natives).toHaveLength(20);
expect(natives).toHaveLength(22);
for (const tool of natives) {
expect({ name: tool.name, arity: tool.execute.length }).toEqual({
name: tool.name,
Expand Down Expand Up @@ -2912,7 +2912,7 @@ describe("main wires the mounted agent-config into createAgentSession", () => {
// board natives are ALWAYS merged in (RIG-1741/RIG-2672/RIG-3191) — so
// customTools carries exactly those, and never a discovered MCP tool.
expect(toolNames(seen[0].customTools)).toContain("agents_spawn_peer");
expect(seen[0].customTools).toHaveLength(20);
expect(seen[0].customTools).toHaveLength(22);
expect(seen[0].enableMCP).toBe(false);
});

Expand Down Expand Up @@ -2945,7 +2945,7 @@ describe("main wires the mounted agent-config into createAgentSession", () => {
// (RIG-1741/RIG-2672/RIG-3191) — so customTools is exactly the comms/
// lifecycle/forge/board natives.
expect(toolNames(seen[0].customTools)).toContain("comms_post_message");
expect(seen[0].customTools).toHaveLength(20);
expect(seen[0].customTools).toHaveLength(22);
});

// ── RIG-1732 T10: COMPASS_ROLE → prompts/<role>/SYSTEM.md → customSystemPrompt ──
Expand Down
104 changes: 77 additions & 27 deletions packages/compass-agent/src/forge.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// ForgeBroker + the ten native forge tools (design:
// ForgeBroker + the twelve native forge tools (design:
// docs/designs/agent/compass-agent-forge-tools/design.md, T1 + T2).
// Each test defends an observable contract of the agent->Runner forge call: the
// exact `ForgeCallRequest` a tool `execute` puts on the wire (arm case, arm
Expand Down Expand Up @@ -46,6 +46,8 @@ import {
listIssuesParameters,
submitReviewParameters,
subscribeParameters,
transitionIssueStateParameters,
transitionPullRequestStateParameters,
unsubscribeParameters,
} from "./forge";
import {
Expand Down Expand Up @@ -197,9 +199,8 @@ describe("ForgeBroker", () => {
);
});
});

describe("createForgeTools", () => {
test("exposes exactly the ten forge tools with the right approvals", () => {
test("exposes all twelve forge tools and registers transitions as writes", () => {
const tools = createForgeTools(
new ForgeBroker(new FakeTransport(issueResult())),
);
Expand All @@ -212,34 +213,19 @@ describe("createForgeTools", () => {
"forge_submit_review",
"forge_create_issue",
"forge_create_pull_request",
"forge_transition_issue_state",
"forge_transition_pull_request_state",
"forge_subscribe",
"forge_unsubscribe",
]);
expect(tools.every((t) => t.label.length > 0)).toBe(true);
expect(tools.every((t) => t.description.length > 0)).toBe(true);
const approvalOf = (n: string) => {
const t = tools.find((x) => x.name === n);
if (!t) throw new Error(`no tool ${n}`);
return t.approval;
};
// Reads auto-approve at "read"; every mutation is a "write" — a silent flip
// to read would broaden auto-approval with nothing else here reddening.
for (const r of [
"forge_get_issue",
"forge_get_pull_request",
"forge_list_issues",
])
expect(approvalOf(r)).toBe("read");
for (const w of [
"forge_comment_on_issue",
"forge_comment_on_pull_request",
"forge_submit_review",
"forge_create_issue",
"forge_create_pull_request",
"forge_subscribe",
"forge_unsubscribe",
expect(
tools.every((t) => t.label.length > 0 && t.description.length > 0),
).toBe(true);
for (const name of [
"forge_transition_issue_state",
"forge_transition_pull_request_state",
])
expect(approvalOf(w)).toBe("write");
expect(tools.find((t) => t.name === name)?.approval).toBe("write");
});
});

Expand Down Expand Up @@ -728,6 +714,51 @@ describe("forge_submit_review", () => {
});
});

describe("forge transition tools", () => {
test("transitions an issue and returns the updated artifact", async () => {
const transport = new FakeTransport(
issueResult({ number: 4, repo: "o/r", forgeState: "closed" }),
);
const result = await exec(
tool(new ForgeBroker(transport), "forge_transition_issue_state"),
"tc-1",
{
repo: "o/r",
issue_number: 4,
state: "closed",
close_reason: "completed",
workflow_state: "Done",
},
);
const req = transport.requests[0];
if (req.call.case !== "transitionIssueState")
throw new Error("expected arm");
expect(req.call.value.issueNumber).toBe(4n);
expect(req.call.value.closeReason).toBe("completed");
expect(req.call.value.workflowState).toBe("Done");
expect(fenceOf(textOf(result))).toHaveLength(8);
});

test("transitions a pull request and returns the updated artifact", async () => {
const transport = new FakeTransport(
pullRequestResult({ number: 5, repo: "o/r", forgeState: "closed" }),
);
const result = await exec(
tool(new ForgeBroker(transport), "forge_transition_pull_request_state"),
"tc-2",
{
repo: "o/r",
pull_number: 5,
state: "closed",
},
);
const req = transport.requests[0];
if (req.call.case !== "transitionPullRequestState")
throw new Error("expected arm");
expect(req.call.value.prNumber).toBe(5n);
expect(fenceOf(textOf(result))).toHaveLength(8);
});
});
describe("forge_create_issue", () => {
test("sets a nonce-prefixed clientRequestId and renders the created ack", async () => {
const transport = new FakeTransport(
Expand Down Expand Up @@ -947,6 +978,25 @@ describe("forge parameter schemas", () => {
const rejects = (schema: Type<object>, params: unknown): boolean =>
schema(params) instanceof ArkErrors;

test("the transition arms reject a state outside the portable domain", () => {
// "merged" and a Linear workflow-state name are both real forge states,
// and neither is a legal TARGET: the portable domain is open|closed.
expect(
rejects(transitionIssueStateParameters, {
repo: "o/r",
issue_number: 4,
state: "merged",
}),
).toBe(true);
expect(
rejects(transitionPullRequestStateParameters, {
repo: "o/r",
pull_number: 5,
state: "Done",
}),
).toBe(true);
});

test("every tool rejects a blank or whitespace-only repo", () => {
for (const schema of [
getIssueParameters,
Expand Down
106 changes: 106 additions & 0 deletions packages/compass-agent/src/forge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ import {
SubscribeForgeRequestSchema,
UnsubscribeForgeRequestSchema,
} from "./compassv1";
import {
TransitionIssueStateRequestSchema,
TransitionPullRequestStateRequestSchema,
} from "./gen/compass/v1/agent_gateway_pb";
import { attr, flat, ref } from "./render-guard";

/**
Expand Down Expand Up @@ -272,6 +276,27 @@ export const unsubscribeParameters = type({
"The id returned by forge_subscribe; must not be blank",
),
});
/** Exported so a test can validate the wire contract the agent loop enforces. */
export const transitionIssueStateParameters = type({
...forgeSelector,
repo: nonBlank(REPO_DESC),
issue_number: type("number.integer >= 1"),
state: type("'open' | 'closed'").describe("Target issue state"),
"close_reason?": type("'completed' | 'not_planned'").describe(
"GitHub issues only; close reason, omitted = provider default",
),
"workflow_state?": type("string").describe(
"Linear only; target workflow state NAME, omitted = default mapping",
),
});

/** Exported so a test can validate the wire contract the agent loop enforces. */
export const transitionPullRequestStateParameters = type({
...forgeSelector,
repo: nonBlank(REPO_DESC),
pull_number: type("number.integer >= 1"),
state: type("'open' | 'closed'").describe("Target pull-request state"),
});

/** Map the tool's optional string provider enum onto the generated `ForgeProvider`. */
function providerEnum(
Expand Down Expand Up @@ -839,6 +864,85 @@ export function createForgeTools(broker: ForgeBroker): AgentTool[] {
},
};

const transitionIssueState: AgentTool<typeof transitionIssueStateParameters> =
{
name: "forge_transition_issue_state",
label: "Transition forge issue state",
approval: "write",
description: `Change an existing issue's state to open or closed and return the post-transition issue. ${REPO_ADDRESSING} ${SCOPE_DISCIPLINE} ${SELECTOR_RULE} close_reason is GitHub-issues-only (completed or not_planned; omitted = provider default); workflow_state is Linear-only (target workflow state NAME; omitted = default mapping).`,
parameters: transitionIssueStateParameters,
execute: async (toolCallId, params) => {
const result = await broker.call(
create(ForgeCallRequestSchema, {
callId: toolCallId,
call: {
case: "transitionIssueState",
value: create(TransitionIssueStateRequestSchema, {
repo: params.repo,
issueNumber: BigInt(params.issue_number),
state: params.state,
closeReason: params.close_reason ?? "",
workflowState: params.workflow_state ?? "",
}),
},
forge: forgeRef(params),
}),
);
if (result.result.case !== "issue")
throw forgeFailure(result, "forge_transition_issue_state", "issue");
const fence = crypto.randomUUID().slice(0, 8);
return {
content: [
{
type: "text",
text: framedRead(renderIssueRecord(result.result.value, fence)),
},
],
};
},
};

const transitionPullRequestState: AgentTool<
typeof transitionPullRequestStateParameters
> = {
name: "forge_transition_pull_request_state",
label: "Transition forge pull request state",
approval: "write",
description: `Change an existing pull request's state to open or closed and return the post-transition pull request (GitHub only). ${REPO_ADDRESSING} ${SCOPE_DISCIPLINE} ${SELECTOR_RULE} No close reason or merge fields are accepted: close reason is an issue concept, and merge is a separate concern.`,
parameters: transitionPullRequestStateParameters,
execute: async (toolCallId, params) => {
const result = await broker.call(
create(ForgeCallRequestSchema, {
callId: toolCallId,
call: {
case: "transitionPullRequestState",
value: create(TransitionPullRequestStateRequestSchema, {
repo: params.repo,
prNumber: BigInt(params.pull_number),
state: params.state,
}),
},
forge: forgeRef(params),
}),
);
if (result.result.case !== "pullRequest")
throw forgeFailure(
result,
"forge_transition_pull_request_state",
"pullRequest",
);
const fence = crypto.randomUUID().slice(0, 8);
return {
content: [
{
type: "text",
text: framedRead(renderPrRecord(result.result.value, fence)),
},
],
};
},
};

const subscribe: AgentTool<typeof subscribeParameters> = {
name: "forge_subscribe",
label: "Subscribe to forge artifact",
Expand Down Expand Up @@ -917,6 +1021,8 @@ export function createForgeTools(broker: ForgeBroker): AgentTool[] {
submitReview,
createIssue,
createPullRequest,
transitionIssueState,
transitionPullRequestState,
subscribe,
unsubscribe,
];
Expand Down
Loading