From 54a0ef1a87dd158ca1947dfcc67ad433fefc32f3 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 7 Sep 2026 23:03:40 -0400 Subject: [PATCH] feat(agent): forge state-transition tools (RIG-3331) --- packages/compass-agent/src/cli.test.ts | 6 +- packages/compass-agent/src/compassv1.ts | 5 ++ packages/compass-agent/src/forge.test.ts | 80 +++++++++++++++-- packages/compass-agent/src/forge.ts | 104 +++++++++++++++++++++++ 4 files changed, 187 insertions(+), 8 deletions(-) diff --git a/packages/compass-agent/src/cli.test.ts b/packages/compass-agent/src/cli.test.ts index 997f8aad..1024ed27 100644 --- a/packages/compass-agent/src/cli.test.ts +++ b/packages/compass-agent/src/cli.test.ts @@ -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, @@ -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); }); @@ -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//SYSTEM.md → customSystemPrompt ── diff --git a/packages/compass-agent/src/compassv1.ts b/packages/compass-agent/src/compassv1.ts index 16f38e32..326a141d 100644 --- a/packages/compass-agent/src/compassv1.ts +++ b/packages/compass-agent/src/compassv1.ts @@ -113,6 +113,11 @@ export { SubscribeForgeRequestSchema, type SubscribeForgeResponse, SubscribeForgeResponseSchema, + // The two forge state-transition arms (RIG-3331): both requests are + // all-scalar and reuse `ForgeCallResult.issue` / `.pull_request`, so no new + // forge domain type rides the wire. + TransitionIssueStateRequestSchema, + TransitionPullRequestStateRequestSchema, UnsubscribeForgeRequestSchema, type UnsubscribeForgeResponse, UnsubscribeForgeResponseSchema, diff --git a/packages/compass-agent/src/forge.test.ts b/packages/compass-agent/src/forge.test.ts index c96cfef7..05106ba3 100644 --- a/packages/compass-agent/src/forge.test.ts +++ b/packages/compass-agent/src/forge.test.ts @@ -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 @@ -46,6 +46,8 @@ import { listIssuesParameters, submitReviewParameters, subscribeParameters, + transitionIssueStateParameters, + transitionPullRequestStateParameters, unsubscribeParameters, } from "./forge"; import { @@ -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())), ); @@ -212,11 +213,14 @@ 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); + expect( + tools.every((t) => t.label.length > 0 && 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}`); @@ -236,6 +240,8 @@ 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", ]) @@ -728,6 +734,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( @@ -947,6 +998,25 @@ describe("forge parameter schemas", () => { const rejects = (schema: Type, 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, diff --git a/packages/compass-agent/src/forge.ts b/packages/compass-agent/src/forge.ts index 3f24928e..10c7f4e8 100644 --- a/packages/compass-agent/src/forge.ts +++ b/packages/compass-agent/src/forge.ts @@ -67,6 +67,8 @@ import { type ReviewRef, SubmitReviewRequestSchema, SubscribeForgeRequestSchema, + TransitionIssueStateRequestSchema, + TransitionPullRequestStateRequestSchema, UnsubscribeForgeRequestSchema, } from "./compassv1"; import { attr, flat, ref } from "./render-guard"; @@ -272,6 +274,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( @@ -839,6 +862,85 @@ export function createForgeTools(broker: ForgeBroker): AgentTool[] { }, }; + const transitionIssueState: AgentTool = + { + 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 = { name: "forge_subscribe", label: "Subscribe to forge artifact", @@ -917,6 +1019,8 @@ export function createForgeTools(broker: ForgeBroker): AgentTool[] { submitReview, createIssue, createPullRequest, + transitionIssueState, + transitionPullRequestState, subscribe, unsubscribe, ];