Skip to content

Commit b0eba4b

Browse files
fix: resolve parentId to UUID in linear_issues tool + add smoke test
The linear_issues tool was passing the identifier string (e.g. "UAT-335") as parentId in the GraphQL mutation instead of the resolved UUID. Linear mutations require UUIDs for ID fields. Now resolves via getIssueDetails. Adds tool-level smoke test (6 tests) that exercises the full linear_issues tool execute path against real Linear API: create parent, read parent, create sub-issue with parentIssueId, verify parent relationship, cleanup. Also adds 2 webhook scenario tests verifying sub-issue guidance appears in agent prompts for triaged issues and is absent for backlog issues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 53121bf commit b0eba4b

4 files changed

Lines changed: 191 additions & 3 deletions

File tree

src/__test__/smoke-linear-api.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { homedir } from "node:os";
1414
import { fileURLToPath } from "node:url";
1515
import { afterAll, beforeAll, describe, expect, it } from "vitest";
1616
import { LinearAgentApi } from "../api/linear-api.js";
17+
import { createLinearIssuesTool } from "../tools/linear-issues-tool.js";
1718

1819
const __dirname = dirname(fileURLToPath(import.meta.url));
1920

@@ -684,6 +685,147 @@ describe("Linear API smoke tests", () => {
684685
});
685686
});
686687

688+
// ---------------------------------------------------------------------------
689+
// Tool-level sub-issue creation (linear_issues tool)
690+
// ---------------------------------------------------------------------------
691+
692+
describe("tool-level sub-issue creation (linear_issues tool)", () => {
693+
let toolParentIdentifier: string | null = null;
694+
let toolSubIdentifier: string | null = null;
695+
let toolParentId: string | null = null;
696+
let toolSubId: string | null = null;
697+
let tool: any;
698+
699+
function parseToolResult(result: any): any {
700+
if (result?.content && Array.isArray(result.content)) {
701+
const textBlock = result.content.find((r: any) => r.type === "text");
702+
if (textBlock) return JSON.parse(textBlock.text);
703+
}
704+
if (result?.details) return result.details;
705+
return typeof result === "string" ? JSON.parse(result) : result;
706+
}
707+
708+
it("instantiates linear_issues tool with real credentials", () => {
709+
const apiKey = loadApiKey();
710+
const pluginApi = {
711+
logger: {
712+
info: (...args: any[]) => console.log("[tool-smoke]", ...args),
713+
warn: (...args: any[]) => console.warn("[tool-smoke]", ...args),
714+
error: (...args: any[]) => console.error("[tool-smoke]", ...args),
715+
debug: () => {},
716+
},
717+
pluginConfig: { accessToken: apiKey },
718+
};
719+
tool = createLinearIssuesTool(pluginApi as any);
720+
expect(tool).toBeTruthy();
721+
expect(tool.name).toBe("linear_issues");
722+
});
723+
724+
it("creates a parent issue via tool action=create", async () => {
725+
const result = parseToolResult(
726+
await tool.execute("smoke-call-1", {
727+
action: "create",
728+
title: "[SMOKE TEST] Tool Sub-Issue Parent",
729+
description:
730+
"Auto-generated by tool-level smoke test.\n" +
731+
"Tests linear_issues tool can create parent + sub-issues.\n\n" +
732+
`Created: ${new Date().toISOString()}`,
733+
teamId: TEAM_ID,
734+
priority: 4,
735+
}),
736+
);
737+
738+
expect(result.error).toBeUndefined();
739+
expect(result.success).toBe(true);
740+
expect(result.identifier).toBeTruthy();
741+
expect(result.id).toBeTruthy();
742+
743+
toolParentIdentifier = result.identifier;
744+
toolParentId = result.id;
745+
console.log(`Tool created parent: ${result.identifier} (${result.id})`);
746+
});
747+
748+
it("reads parent issue via tool action=read (by identifier)", async () => {
749+
expect(toolParentIdentifier).toBeTruthy();
750+
751+
const result = parseToolResult(
752+
await tool.execute("smoke-call-2", {
753+
action: "read",
754+
issueId: toolParentIdentifier!,
755+
}),
756+
);
757+
758+
expect(result.error).toBeUndefined();
759+
expect(result.identifier).toBe(toolParentIdentifier);
760+
expect(result.title).toContain("[SMOKE TEST]");
761+
expect(result.team.id).toBe(TEAM_ID);
762+
expect(result.parent).toBeNull();
763+
console.log(`Tool read parent: ${result.identifier} (status=${result.status})`);
764+
});
765+
766+
it("creates a sub-issue via tool action=create with parentIssueId (identifier)", async () => {
767+
expect(toolParentIdentifier).toBeTruthy();
768+
769+
const result = parseToolResult(
770+
await tool.execute("smoke-call-3", {
771+
action: "create",
772+
title: "[SMOKE TEST] Tool Sub-Issue: Backend work",
773+
description:
774+
"Sub-issue created via linear_issues tool with parentIssueId.\n" +
775+
"Verifies identifier → UUID resolution and teamId inheritance.",
776+
parentIssueId: toolParentIdentifier!,
777+
priority: 3,
778+
estimate: 2,
779+
}),
780+
);
781+
782+
expect(result.error).toBeUndefined();
783+
expect(result.success).toBe(true);
784+
expect(result.identifier).toBeTruthy();
785+
expect(result.id).toBeTruthy();
786+
expect(result.parentIssueId).toBe(toolParentIdentifier);
787+
788+
toolSubIdentifier = result.identifier;
789+
toolSubId = result.id;
790+
console.log(`Tool created sub-issue: ${result.identifier} (parent=${toolParentIdentifier})`);
791+
});
792+
793+
it("verifies sub-issue has correct parent via tool action=read", async () => {
794+
expect(toolSubIdentifier).toBeTruthy();
795+
796+
const result = parseToolResult(
797+
await tool.execute("smoke-call-4", {
798+
action: "read",
799+
issueId: toolSubIdentifier!,
800+
}),
801+
);
802+
803+
expect(result.error).toBeUndefined();
804+
expect(result.identifier).toBe(toolSubIdentifier);
805+
expect(result.parent).not.toBeNull();
806+
expect(result.parent.identifier).toBe(toolParentIdentifier);
807+
// teamId was inherited from parent (not provided explicitly in create)
808+
expect(result.team.id).toBe(TEAM_ID);
809+
console.log(`Tool sub-issue parent confirmed: ${result.parent.identifier}`);
810+
});
811+
812+
it("cleans up: cancels tool-created issues", async () => {
813+
const states = await api.getTeamStates(TEAM_ID);
814+
const canceledState = states.find(
815+
(s) => s.type === "canceled" || s.name.toLowerCase().includes("cancel"),
816+
);
817+
818+
for (const id of [toolSubId, toolParentId]) {
819+
if (!id || !canceledState) continue;
820+
try {
821+
await api.updateIssue(id, { stateId: canceledState.id });
822+
} catch {
823+
// Best effort
824+
}
825+
}
826+
});
827+
});
828+
687829
describe("cleanup", () => {
688830
it("cancels the smoke test issue", async () => {
689831
if (!smokeIssueId) return;

src/__test__/webhook-scenarios.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ vi.mock("../pipeline/pipeline.js", () => ({
8282
runPlannerStage: vi.fn().mockResolvedValue("mock plan"),
8383
runFullPipeline: vi.fn().mockResolvedValue(undefined),
8484
resumePipeline: vi.fn().mockResolvedValue(undefined),
85+
buildProjectContext: () => "",
8586
}));
8687

8788
vi.mock("../pipeline/active-session.js", () => ({
@@ -787,4 +788,47 @@ describe("webhook scenario tests — full handler flows", () => {
787788
expect(msg).toContain("Cached guidance from session event");
788789
});
789790
});
791+
792+
describe("Sub-issue guidance in agent prompt", () => {
793+
it("created: triaged issue includes sub-issue guidance with parentIssueId", async () => {
794+
// Issue is "In Progress" (type: "started") — triaged, so full tool access
795+
mockGetIssueDetails.mockResolvedValue(makeIssueDetails({
796+
state: { name: "In Progress", type: "started" },
797+
}));
798+
799+
const api = createApi();
800+
const payload = makeAgentSessionEventCreated();
801+
await postWebhook(api, payload);
802+
803+
await waitForMock(mockClearActiveSession);
804+
805+
expect(mockRunAgent).toHaveBeenCalledOnce();
806+
const msg = mockRunAgent.mock.calls[0][0].message;
807+
808+
// Verify sub-issue guidance text includes the correct parentIssueId
809+
expect(msg).toContain("Sub-issue guidance");
810+
expect(msg).toContain("break it into sub-issues");
811+
expect(msg).toContain('parentIssueId="ENG-123"');
812+
});
813+
814+
it("created: backlog issue does NOT include sub-issue guidance", async () => {
815+
// Issue is "Backlog" (type: "backlog") — untriaged, so read-only tool access
816+
mockGetIssueDetails.mockResolvedValue(makeIssueDetails({
817+
state: { name: "Backlog", type: "backlog" },
818+
}));
819+
820+
const api = createApi();
821+
const payload = makeAgentSessionEventCreated();
822+
await postWebhook(api, payload);
823+
824+
await waitForMock(mockClearActiveSession);
825+
826+
expect(mockRunAgent).toHaveBeenCalledOnce();
827+
const msg = mockRunAgent.mock.calls[0][0].message;
828+
829+
// Backlog issues get READ ONLY access — no sub-issue guidance
830+
expect(msg).not.toContain("Sub-issue guidance");
831+
expect(msg).toContain("READ ONLY");
832+
});
833+
});
790834
});

src/tools/linear-issues-tool.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ describe("linear_issues tool", () => {
342342
projectId: "proj-1",
343343
title: "Sub-task: handle edge case",
344344
description: "Fix the edge case for empty input",
345-
parentId: "ENG-123",
345+
parentId: "issue-1", // Resolved UUID from getIssueDetails, not the identifier "ENG-123"
346346
});
347347
expect(result.success).toBe(true);
348348
expect(result.identifier).toBe("ENG-201");

src/tools/linear-issues-tool.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,14 @@ async function handleCreate(api: LinearAgentApi, params: ToolParams) {
8686
// Resolve teamId: explicit param, or derive from parent issue
8787
let teamId = params.teamId;
8888
let projectId = params.projectId;
89+
let resolvedParentId: string | undefined;
8990

9091
if (params.parentIssueId) {
91-
// Fetch parent to get teamId and projectId
92+
// Fetch parent to get teamId, projectId, and resolved UUID
9293
const parent = await api.getIssueDetails(params.parentIssueId);
9394
teamId = teamId ?? parent.team.id;
9495
projectId = projectId ?? parent.project?.id ?? undefined;
96+
resolvedParentId = parent.id;
9597
}
9698

9799
if (!teamId) {
@@ -106,7 +108,7 @@ async function handleCreate(api: LinearAgentApi, params: ToolParams) {
106108
};
107109

108110
if (params.description) input.description = params.description;
109-
if (params.parentIssueId) input.parentId = params.parentIssueId;
111+
if (resolvedParentId) input.parentId = resolvedParentId;
110112
if (projectId) input.projectId = projectId;
111113
if (params.priority != null) input.priority = params.priority;
112114
if (params.estimate != null) input.estimate = params.estimate;

0 commit comments

Comments
 (0)