|
14 | 14 |
|
15 | 15 | import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; |
16 | 16 |
|
17 | | -import { validateBlocksPlan, compileBlocksPlanToDag } from "../langgraph/compiler"; |
| 17 | +import { validateBlocksPlan, compileBlocksPlanToDag, inferMissingResources, detectPkgManifestConflicts } from "../langgraph/compiler"; |
18 | 18 | import { |
19 | 19 | selectRunnable, |
20 | 20 | getCancellableForRace, |
@@ -934,3 +934,315 @@ describe("runPlannedStep — semantic failure detection", () => { |
934 | 934 | expect(STEP_FAILED_MARKERS.length).toBeGreaterThan(0); |
935 | 935 | }); |
936 | 936 | }); |
| 937 | + |
| 938 | +// ───────────────────────────────────────────────────────────────────────────── |
| 939 | +// (13) inferMissingResources — auto-inference of file:write resources |
| 940 | +// ───────────────────────────────────────────────────────────────────────────── |
| 941 | + |
| 942 | +describe("inferMissingResources", () => { |
| 943 | + it("adds file:write:package.json for an npm install step", () => { |
| 944 | + const result = inferMissingResources("Run npm install @anthropic-ai/sdk", [], []); |
| 945 | + expect(result).toContain("file:write:package.json"); |
| 946 | + }); |
| 947 | + |
| 948 | + it("adds file:write:package.json for a yarn add step", () => { |
| 949 | + const result = inferMissingResources("yarn add typescript --dev", [], []); |
| 950 | + expect(result).toContain("file:write:package.json"); |
| 951 | + }); |
| 952 | + |
| 953 | + it("adds file:write:package.json for a pnpm install step", () => { |
| 954 | + const result = inferMissingResources("pnpm install", [], []); |
| 955 | + expect(result).toContain("file:write:package.json"); |
| 956 | + }); |
| 957 | + |
| 958 | + it("adds file:write:requirements.txt for a pip install step", () => { |
| 959 | + const result = inferMissingResources("pip install requests", [], []); |
| 960 | + expect(result).toContain("file:write:requirements.txt"); |
| 961 | + }); |
| 962 | + |
| 963 | + it("adds file:write:cargo.toml for a cargo build step", () => { |
| 964 | + const result = inferMissingResources("cargo build --release", [], []); |
| 965 | + expect(result).toContain("file:write:cargo.toml"); |
| 966 | + }); |
| 967 | + |
| 968 | + it("adds file:write:go.mod for a go get step", () => { |
| 969 | + const result = inferMissingResources("go get github.com/some/pkg", [], []); |
| 970 | + expect(result).toContain("file:write:go.mod"); |
| 971 | + }); |
| 972 | + |
| 973 | + it("does not duplicate a resource already declared", () => { |
| 974 | + const existing = ["file:write:package.json"]; |
| 975 | + const result = inferMissingResources("npm install lodash", [], existing); |
| 976 | + expect(result).not.toContain("file:write:package.json"); |
| 977 | + }); |
| 978 | + |
| 979 | + it("adds file:write:package.json when file-edit tool targets package.json", () => { |
| 980 | + const result = inferMissingResources( |
| 981 | + "Edit package.json to add the @anthropic-ai/sdk dependency", |
| 982 | + ["file-edit"], |
| 983 | + [], |
| 984 | + ); |
| 985 | + expect(result).toContain("file:write:package.json"); |
| 986 | + }); |
| 987 | + |
| 988 | + it("adds file:write:requirements.txt when file-write targets requirements.txt", () => { |
| 989 | + const result = inferMissingResources( |
| 990 | + "Write updated requirements.txt with new package versions", |
| 991 | + ["file-write"], |
| 992 | + [], |
| 993 | + ); |
| 994 | + expect(result).toContain("file:write:requirements.txt"); |
| 995 | + }); |
| 996 | + |
| 997 | + it("does not infer file:write resources for an unrelated file-edit step", () => { |
| 998 | + const result = inferMissingResources( |
| 999 | + "Edit src/index.ts to add new export", |
| 1000 | + ["file-edit"], |
| 1001 | + [], |
| 1002 | + ); |
| 1003 | + expect(result).toHaveLength(0); |
| 1004 | + }); |
| 1005 | + |
| 1006 | + it("returns empty array for a step with no pkg-manager pattern and no toolsNeeded", () => { |
| 1007 | + const result = inferMissingResources("Run the test suite", [], []); |
| 1008 | + expect(result).toHaveLength(0); |
| 1009 | + }); |
| 1010 | +}); |
| 1011 | + |
| 1012 | +// ───────────────────────────────────────────────────────────────────────────── |
| 1013 | +// (14) compileBlocksPlanToDag — auto-inference integration |
| 1014 | +// ───────────────────────────────────────────────────────────────────────────── |
| 1015 | + |
| 1016 | +describe("compileBlocksPlanToDag — pkg-manager resource inference", () => { |
| 1017 | + it("auto-adds file:write:package.json to an npm install step with no resources", () => { |
| 1018 | + const plan: BlocksPlan = { |
| 1019 | + version: "2.0", |
| 1020 | + goal: "install dep", |
| 1021 | + blocks: [ |
| 1022 | + { |
| 1023 | + type: "step", |
| 1024 | + description: "npm install @anthropic-ai/sdk", |
| 1025 | + toolsNeeded: [], |
| 1026 | + estimatedComplexity: "low", |
| 1027 | + }, |
| 1028 | + ], |
| 1029 | + }; |
| 1030 | + const dag = compileBlocksPlanToDag(plan); |
| 1031 | + const node = Object.values(dag.nodes)[0]; |
| 1032 | + expect(node.resources).toContain("file:write:package.json"); |
| 1033 | + }); |
| 1034 | + |
| 1035 | + it("auto-adds file:write:package.json to a file-edit step targeting package.json", () => { |
| 1036 | + const plan: BlocksPlan = { |
| 1037 | + version: "2.0", |
| 1038 | + goal: "edit manifest", |
| 1039 | + blocks: [ |
| 1040 | + { |
| 1041 | + type: "step", |
| 1042 | + description: "Edit package.json to add the new dependency", |
| 1043 | + toolsNeeded: ["file-edit"], |
| 1044 | + estimatedComplexity: "low", |
| 1045 | + }, |
| 1046 | + ], |
| 1047 | + }; |
| 1048 | + const dag = compileBlocksPlanToDag(plan); |
| 1049 | + const node = Object.values(dag.nodes)[0]; |
| 1050 | + expect(node.resources).toContain("file:write:package.json"); |
| 1051 | + }); |
| 1052 | + |
| 1053 | + it("serialises npm-install and file-edit-package.json steps via inferred resource locks", () => { |
| 1054 | + // Simulate the exact scenario from H-1: npm install and file-edit package.json in parallel |
| 1055 | + const plan: BlocksPlan = { |
| 1056 | + version: "2.0", |
| 1057 | + goal: "add dependency", |
| 1058 | + blocks: [ |
| 1059 | + { |
| 1060 | + type: "parallel", |
| 1061 | + join: "all", |
| 1062 | + branches: [ |
| 1063 | + { |
| 1064 | + name: "install", |
| 1065 | + blocks: [ |
| 1066 | + { |
| 1067 | + type: "step", |
| 1068 | + description: "npm install @anthropic-ai/sdk@latest", |
| 1069 | + toolsNeeded: [], |
| 1070 | + estimatedComplexity: "low", |
| 1071 | + // Note: no resources declared — compiler must infer |
| 1072 | + }, |
| 1073 | + ], |
| 1074 | + }, |
| 1075 | + { |
| 1076 | + name: "edit-manifest", |
| 1077 | + blocks: [ |
| 1078 | + { |
| 1079 | + type: "step", |
| 1080 | + description: "Edit package.json to add @anthropic-ai/sdk ^0.25.3", |
| 1081 | + toolsNeeded: ["file-edit"], |
| 1082 | + estimatedComplexity: "low", |
| 1083 | + // Note: no resources declared — compiler must infer |
| 1084 | + }, |
| 1085 | + ], |
| 1086 | + }, |
| 1087 | + ], |
| 1088 | + }, |
| 1089 | + ], |
| 1090 | + }; |
| 1091 | + |
| 1092 | + const dag = compileBlocksPlanToDag(plan); |
| 1093 | + const records: Record<string, NodeRecord> = {}; |
| 1094 | + for (const id of Object.keys(dag.nodes)) { |
| 1095 | + records[id] = { nodeId: id, status: "pending", retryCount: 0 }; |
| 1096 | + } |
| 1097 | + |
| 1098 | + // Both branch steps depend on nothing (no prior step), so they would |
| 1099 | + // normally both be runnable simultaneously. With inferred resource locks |
| 1100 | + // on the same file, the scheduler must serialise them. |
| 1101 | + const runnable = selectRunnable(dag, records, { maxConcurrency: 10, networkConcurrency: 10 }); |
| 1102 | + |
| 1103 | + const npmStep = Object.values(dag.nodes).find((n) => |
| 1104 | + n.description.includes("npm install"), |
| 1105 | + )!; |
| 1106 | + const editStep = Object.values(dag.nodes).find((n) => |
| 1107 | + n.description.includes("Edit package.json"), |
| 1108 | + )!; |
| 1109 | + |
| 1110 | + // Both must have the inferred lock |
| 1111 | + expect(npmStep.resources).toContain("file:write:package.json"); |
| 1112 | + expect(editStep.resources).toContain("file:write:package.json"); |
| 1113 | + |
| 1114 | + // Only one of the conflicting steps should be selected as runnable |
| 1115 | + const conflictingRunnable = runnable.filter( |
| 1116 | + (id) => id === npmStep.id || id === editStep.id, |
| 1117 | + ); |
| 1118 | + expect(conflictingRunnable.length).toBeLessThanOrEqual(1); |
| 1119 | + }); |
| 1120 | +}); |
| 1121 | + |
| 1122 | +// ───────────────────────────────────────────────────────────────────────────── |
| 1123 | +// (15) detectPkgManifestConflicts — post-plan validation |
| 1124 | +// ───────────────────────────────────────────────────────────────────────────── |
| 1125 | + |
| 1126 | +describe("detectPkgManifestConflicts", () => { |
| 1127 | + it("detects a conflict when npm-install and file-edit run in parallel without resource locks", () => { |
| 1128 | + const plan: BlocksPlan = { |
| 1129 | + version: "2.0", |
| 1130 | + goal: "add dependency", |
| 1131 | + blocks: [ |
| 1132 | + { |
| 1133 | + type: "parallel", |
| 1134 | + join: "all", |
| 1135 | + branches: [ |
| 1136 | + { |
| 1137 | + name: "install", |
| 1138 | + blocks: [ |
| 1139 | + { type: "step", description: "npm install @anthropic-ai/sdk@latest", toolsNeeded: [], estimatedComplexity: "low" }, |
| 1140 | + ], |
| 1141 | + }, |
| 1142 | + { |
| 1143 | + name: "edit", |
| 1144 | + blocks: [ |
| 1145 | + { type: "step", description: "Edit package.json to add the dependency", toolsNeeded: ["file-edit"], estimatedComplexity: "low" }, |
| 1146 | + ], |
| 1147 | + }, |
| 1148 | + ], |
| 1149 | + }, |
| 1150 | + ], |
| 1151 | + }; |
| 1152 | + |
| 1153 | + const conflicts = detectPkgManifestConflicts(plan); |
| 1154 | + expect(conflicts.length).toBeGreaterThan(0); |
| 1155 | + expect(conflicts[0]).toContain("install"); |
| 1156 | + expect(conflicts[0]).toContain("edit"); |
| 1157 | + }); |
| 1158 | + |
| 1159 | + it("returns no conflicts when npm-install and file-edit are sequential", () => { |
| 1160 | + const plan: BlocksPlan = { |
| 1161 | + version: "2.0", |
| 1162 | + goal: "add dependency", |
| 1163 | + blocks: [ |
| 1164 | + { type: "step", description: "npm install @anthropic-ai/sdk@latest", toolsNeeded: [], estimatedComplexity: "low" }, |
| 1165 | + { type: "step", description: "Edit package.json to add the dependency", toolsNeeded: ["file-edit"], estimatedComplexity: "low" }, |
| 1166 | + ], |
| 1167 | + }; |
| 1168 | + |
| 1169 | + const conflicts = detectPkgManifestConflicts(plan); |
| 1170 | + expect(conflicts).toHaveLength(0); |
| 1171 | + }); |
| 1172 | + |
| 1173 | + it("returns no conflicts when both steps declare matching resource locks", () => { |
| 1174 | + const plan: BlocksPlan = { |
| 1175 | + version: "2.0", |
| 1176 | + goal: "add dependency", |
| 1177 | + blocks: [ |
| 1178 | + { |
| 1179 | + type: "parallel", |
| 1180 | + join: "all", |
| 1181 | + branches: [ |
| 1182 | + { |
| 1183 | + name: "install", |
| 1184 | + blocks: [ |
| 1185 | + { |
| 1186 | + type: "step", |
| 1187 | + description: "npm install @anthropic-ai/sdk@latest", |
| 1188 | + toolsNeeded: [], |
| 1189 | + estimatedComplexity: "low", |
| 1190 | + resources: ["file:WRITE:package.json"], |
| 1191 | + }, |
| 1192 | + ], |
| 1193 | + }, |
| 1194 | + { |
| 1195 | + name: "edit", |
| 1196 | + blocks: [ |
| 1197 | + { |
| 1198 | + type: "step", |
| 1199 | + description: "Edit package.json to add the dependency", |
| 1200 | + toolsNeeded: ["file-edit"], |
| 1201 | + estimatedComplexity: "low", |
| 1202 | + resources: ["file:WRITE:package.json"], |
| 1203 | + }, |
| 1204 | + ], |
| 1205 | + }, |
| 1206 | + ], |
| 1207 | + }, |
| 1208 | + ], |
| 1209 | + }; |
| 1210 | + |
| 1211 | + // Both sides have a declared lock on package.json — they overlap, so |
| 1212 | + // this is still detected as a conflict (overlapping write = conflict). |
| 1213 | + // The scheduler will serialise them via the lock, but the plan itself |
| 1214 | + // is structurally problematic. |
| 1215 | + const conflicts = detectPkgManifestConflicts(plan); |
| 1216 | + expect(conflicts.length).toBeGreaterThan(0); |
| 1217 | + }); |
| 1218 | + |
| 1219 | + it("returns no conflicts for a plan with no parallel blocks", () => { |
| 1220 | + const plan: BlocksPlan = { |
| 1221 | + version: "2.0", |
| 1222 | + goal: "simple plan", |
| 1223 | + blocks: [ |
| 1224 | + { type: "step", description: "npm install lodash", toolsNeeded: [], estimatedComplexity: "low" }, |
| 1225 | + { type: "step", description: "Edit package.json to pin version", toolsNeeded: ["file-edit"], estimatedComplexity: "low" }, |
| 1226 | + ], |
| 1227 | + }; |
| 1228 | + expect(detectPkgManifestConflicts(plan)).toHaveLength(0); |
| 1229 | + }); |
| 1230 | + |
| 1231 | + it("returns no conflicts when parallel branches are unrelated to pkg-manager", () => { |
| 1232 | + const plan: BlocksPlan = { |
| 1233 | + version: "2.0", |
| 1234 | + goal: "fetch two things", |
| 1235 | + blocks: [ |
| 1236 | + { |
| 1237 | + type: "parallel", |
| 1238 | + join: "all", |
| 1239 | + branches: [ |
| 1240 | + { name: "a", blocks: [{ type: "step", description: "Fetch docs page", toolsNeeded: ["web-fetch"], estimatedComplexity: "low", resources: ["network"] }] }, |
| 1241 | + { name: "b", blocks: [{ type: "step", description: "Fetch changelog", toolsNeeded: ["web-fetch"], estimatedComplexity: "low", resources: ["network"] }] }, |
| 1242 | + ], |
| 1243 | + }, |
| 1244 | + ], |
| 1245 | + }; |
| 1246 | + expect(detectPkgManifestConflicts(plan)).toHaveLength(0); |
| 1247 | + }); |
| 1248 | +}); |
0 commit comments